Compare commits
No commits in common. "e9957c552aca51d23fbc65646a83d982297e3579" and "50eae17c3411fda5087fab4d7a429316e0576500" have entirely different histories.
e9957c552a
...
50eae17c34
43 changed files with 330 additions and 1784 deletions
|
|
@ -1,11 +0,0 @@
|
||||||
.git
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
content/
|
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
# === REQUIRED (generated automatically by setup.sh) ===
|
# === REQUIRED (generated automatically by setup.sh) ===
|
||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
API_TOKEN=
|
|
||||||
|
|
||||||
# === APP URL (change for production) ===
|
# === APP URL (change for production) ===
|
||||||
BASE_URL=http://localhost
|
BASE_URL=http://localhost
|
||||||
|
|
@ -9,11 +8,6 @@ BASE_URL=http://localhost
|
||||||
DB_USER=fitness
|
DB_USER=fitness
|
||||||
DB_NAME=fitness_rewards
|
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,
|
# Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
|
||||||
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
|
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
|
||||||
# No container restart needed.
|
# No container restart needed.
|
||||||
|
|
|
||||||
|
|
@ -97,31 +97,3 @@ Use get_context() to see their motivation profile:
|
||||||
understand it, but don't lecture.
|
understand it, but don't lecture.
|
||||||
- Don't read back integration credentials. You can check status but
|
- Don't read back integration credentials. You can check status but
|
||||||
never retrieve stored secrets.
|
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
27
CHANGELOG.md
|
|
@ -1,27 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to Diligence are documented here.
|
|
||||||
|
|
||||||
## [1.0.0] — 2026-06-18
|
|
||||||
|
|
||||||
Initial open-source release.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Points economy with daily gate, weekly targets, weekly reset
|
|
||||||
- Science-based onboarding (PAR-Q+, TTM, BREQ-2 motivation profiling)
|
|
||||||
- Activity logging (manual + Strava OAuth + Polar OAuth sync)
|
|
||||||
- Food logging with Open Food Facts barcode scanning + USDA FoodData Central
|
|
||||||
- 90-day structured program tracking
|
|
||||||
- Configurable reward shop
|
|
||||||
- In-app support chat with Telegram notifications
|
|
||||||
- 14-tool MCP connector for AI agent integration
|
|
||||||
- Meal plan system with compliance tracking
|
|
||||||
- Dynamic integration configuration (11 providers)
|
|
||||||
- B2A discovery layer (llms.txt, agent-card.json, SKILL.md)
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- Fernet-encrypted credential storage (HKDF key derivation)
|
|
||||||
- Signed JWT OAuth state parameters
|
|
||||||
- MCP connector authentication via API_TOKEN
|
|
||||||
- First-user auto-admin grant
|
|
||||||
- Traceback suppression in error responses
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# Contributing to Diligence
|
|
||||||
|
|
||||||
Thanks for your interest in contributing!
|
|
||||||
|
|
||||||
## How to contribute
|
|
||||||
|
|
||||||
1. **Open an issue** to discuss the change before starting work
|
|
||||||
2. **Fork the repo** and create a branch from `main`
|
|
||||||
3. **Test locally**: `docker compose up -d` and verify all 4 containers start healthy
|
|
||||||
4. **Submit a PR** with a clear description of what changed and why
|
|
||||||
|
|
||||||
## Development setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/DiligenceWorks/Diligence.git
|
|
||||||
cd Diligence
|
|
||||||
./setup.sh # Mac/Linux
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open http://localhost and register. First user gets admin automatically.
|
|
||||||
|
|
||||||
## Code style
|
|
||||||
|
|
||||||
- Backend: Python 3.11+, FastAPI, async/await, type hints
|
|
||||||
- Frontend: React with hooks, no class components
|
|
||||||
- CSS: use CSS variables from `index.css`, never hardcode colors
|
|
||||||
|
|
||||||
## Questions?
|
|
||||||
|
|
||||||
Open a GitHub Discussion or reach out at hello@diligenceworks.online.
|
|
||||||
101
OUTSTANDING.md
101
OUTSTANDING.md
|
|
@ -1,101 +0,0 @@
|
||||||
# Diligence v4 — Outstanding Work
|
|
||||||
|
|
||||||
**Last updated:** June 22, 2026
|
|
||||||
**Session summary:** 5 commits, 35 files changed, +1,358/-329 lines
|
|
||||||
|
|
||||||
## What Was Delivered (June 22, 2026)
|
|
||||||
|
|
||||||
### Stream A — DW Branding ✅ COMPLETE
|
|
||||||
- Full palette swap from "Solar Momentum" (orange/green/blue) to DiligenceWorks brand (deep blue #2952CC, teal, IBM Plex Mono)
|
|
||||||
- Light and dark mode via `prefers-color-scheme` media query + manual `data-theme` override
|
|
||||||
- Typography: Outfit → Instrument Sans, Plus Jakarta Sans → IBM Plex Mono for data elements
|
|
||||||
- 30+ hardcoded hex color values replaced across 16 JSX files
|
|
||||||
- Border radii tightened from 14/10/20px to 8/6/12px
|
|
||||||
|
|
||||||
### Stream D — Security Hardening ✅ COMPLETE
|
|
||||||
- SEC-05: Fail-fast if SECRET_KEY is the default value
|
|
||||||
- SEC-06: CORS `allow_credentials=False`
|
|
||||||
- SEC-08: Input validation on auth schemas (username length/pattern, password min 8 chars)
|
|
||||||
- SEC-10: `.dockerignore` files for root, backend, mcp-connector
|
|
||||||
- SEC-11: JWT algorithm hardcoded as constant
|
|
||||||
- CQ-01: Crawl scheduler gated on `CRAWL_ENABLED` env var
|
|
||||||
- OS-02: `CONTRIBUTING.md`
|
|
||||||
- OS-03: `CHANGELOG.md` (v1.0.0)
|
|
||||||
- `setup.ps1`: API_TOKEN generation (Windows parity)
|
|
||||||
|
|
||||||
### Stream C — AI Agent Setup ✅ BACKEND + FRONTEND COMPLETE
|
|
||||||
- `ai_provider.py`: Multi-provider LLM routing with 2 code paths (OpenAI-compatible + Anthropic)
|
|
||||||
- Streaming SSE responses for all providers
|
|
||||||
- `ai_chat.py`: POST `/api/ai/chat` (SSE) + GET `/api/ai/status`
|
|
||||||
- Provider registry expanded from 11 to 20 providers across 4 categories
|
|
||||||
- `ChatCoach.jsx`: In-app chat UI with streaming, provider indicator, suggestion chips
|
|
||||||
- Nav updated: Home | Log | Keto | Programs | **Coach**
|
|
||||||
- README updated with 8-provider table + Claude Desktop/Code/Cursor/Windsurf configs
|
|
||||||
- AGENT_GUIDE updated with multi-MCP, Strava warning, provider-agnostic statement
|
|
||||||
|
|
||||||
### Stream B — Hardware Integrations ✅ FOUNDATION COMPLETE
|
|
||||||
- `device_sync_base.py`: Abstract base class with OAuth management, deduplication, webhook interface
|
|
||||||
- Provider registry includes Garmin, WHOOP, Oura, COROS, Fitbit, Withings, Suunto with accurate API URLs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Still Needs Doing
|
|
||||||
|
|
||||||
### Priority 1 — Blocked on API Credentials (Scot action items)
|
|
||||||
|
|
||||||
| Task | Blocker | Effort once unblocked |
|
|
||||||
|------|---------|----------------------|
|
|
||||||
| `garmin_sync.py` — Garmin Connect sync service | Apply at developer.garmin.com using DiligenceWorks Pte. Ltd. ~2 business days approval | 2-3 hours |
|
|
||||||
| `whoop_sync.py` — WHOOP sync service | Register at developer.whoop.com. Requires WHOOP device ownership | 2-3 hours |
|
|
||||||
| `oura_sync.py` — Oura Ring sync service | Register at cloud.ouraring.com. Self-serve, immediate | 2-3 hours |
|
|
||||||
| COROS partner API application | Apply at support.coros.com. Timeline unclear | MCP bridge already designed, no backend code needed |
|
|
||||||
|
|
||||||
Each sync service follows the pattern in `device_sync_base.py` and mirrors the existing `strava_sync.py`. The work is: OAuth flow, activity type mapping, API data fetching, and point awarding via `import_activity()`.
|
|
||||||
|
|
||||||
### Priority 2 — Code Work (no blockers)
|
|
||||||
|
|
||||||
| Task | Effort | Notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| Generic webhook receiver endpoint (`POST /api/integrations/webhook/{provider}`) | 1-2 hours | Dispatches to sync services when devices push data |
|
|
||||||
| Theme toggle UI in Settings page | 30 min | Light/dark/system toggle, stored in localStorage |
|
|
||||||
| SEC-07: Auth rate limiting (`slowapi` on login/register) | 30 min | Add `slowapi` to requirements.txt |
|
|
||||||
| SEC-09: UUID parameter validation in meal_plans/support routers | 20 min | Change `str` → `uuid.UUID` type hints |
|
|
||||||
| SEC-15: Enum validation for compliance status and meal_type | 20 min | Add `Literal[...]` types |
|
|
||||||
| UI-03: Fix React Hooks violation in HelpButton (App.jsx) | 10 min | Move `useEffect` above conditional return |
|
|
||||||
| UI-04: Load existing compliance data in MealPlan.jsx on mount | 30 min | Fetch from API and pre-populate state |
|
|
||||||
| UI-05: Error state handling in SettingsIntegrations + MealPlan | 30 min | Add error banners on API failures |
|
|
||||||
| Settings page accessibility after nav change | 20 min | Add gear icon in header or link from Coach page |
|
|
||||||
| Function calling for AI chat (tool use) | 2-3 hours | Let AI coach log workouts, search food via internal tools |
|
|
||||||
|
|
||||||
### Priority 3 — Polish (nice to have)
|
|
||||||
|
|
||||||
| Task | Effort |
|
|
||||||
|------|--------|
|
|
||||||
| Push updated code to GitHub (DiligenceWorks/Diligence) — clean-history push | 30 min |
|
|
||||||
| Deploy v4 to Coolify (fitness.littlefake.com) | 20 min |
|
|
||||||
| Update DW KB CONTEXT.md for fitness-rewards project | 20 min |
|
|
||||||
| OS-06: GitHub Actions CI pipeline (docker compose build) | 30 min |
|
|
||||||
| OS-07: Test suite (auth, points engine, meal plans) | 4-6 hours |
|
|
||||||
| OS-08: System requirements in README (RAM, disk, Docker version) | 10 min |
|
|
||||||
| OS-09: Backup/restore documentation in README | 10 min |
|
|
||||||
| UI-08: Default timezone from UTC instead of Asia/Bangkok | 5 min |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Summary for Reviewers
|
|
||||||
|
|
||||||
**Container count:** 4 (unchanged — frontend, backend, mcp-connector, fitness-db)
|
|
||||||
|
|
||||||
**AI coaching flow:**
|
|
||||||
User types in Coach tab → POST `/api/ai/chat` → `ai_provider.py` reads configured provider from DB → routes to OpenAI-compatible endpoint OR Anthropic API → streams SSE response back to `ChatCoach.jsx`
|
|
||||||
|
|
||||||
**Device sync flow:**
|
|
||||||
User connects device via OAuth in Settings → credentials encrypted in `integration_configs` table → sync triggered by webhook (push) or manual sync (pull) → `device_sync_base.py` deduplicates and awards points via `points_engine.py`
|
|
||||||
|
|
||||||
**Provider registry:** 20 providers organized by category:
|
|
||||||
- **Device** (9): Strava✅, Polar✅, Garmin⏳, WHOOP⏳, Oura⏳, COROS (MCP), Fitbit, Withings, Suunto
|
|
||||||
- **AI** (8): OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Claude, Gemini, Custom
|
|
||||||
- **Nutrition** (2): USDA✅, Nutritionix
|
|
||||||
- **Notifications** (1): Telegram✅
|
|
||||||
|
|
||||||
✅ = working sync service, ⏳ = sync service pending API credentials
|
|
||||||
190
README.md
190
README.md
|
|
@ -6,65 +6,6 @@ Built by [DiligenceWorks](https://diligenceworks.online).
|
||||||
|
|
||||||
## Quick Start
|
## 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
|
```bash
|
||||||
git clone https://github.com/diligenceworks/diligence
|
git clone https://github.com/diligenceworks/diligence
|
||||||
cd diligence
|
cd diligence
|
||||||
|
|
@ -72,30 +13,7 @@ cd diligence
|
||||||
docker compose up -d
|
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
|
## Features
|
||||||
|
|
||||||
|
|
@ -108,71 +26,17 @@ You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`
|
||||||
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
|
- **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.
|
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
|
||||||
|
|
||||||
## Built-in AI Coach
|
## Connecting an AI Agent
|
||||||
|
|
||||||
Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab.
|
Point your agent's MCP config to `http://localhost:3001/sse` (development) or `https://your-domain/mcp` (production behind reverse proxy).
|
||||||
|
|
||||||
Supported providers (one API key, that's it):
|
Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
|
||||||
|
|
||||||
| 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.
|
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
|
||||||
|
|
||||||
## Configuring Integrations
|
## 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
|
## Data Sovereignty
|
||||||
|
|
||||||
|
|
@ -180,57 +44,17 @@ Diligence is self-hosted software. Your data never leaves your server. No cloud
|
||||||
|
|
||||||
Your fitness data — heart rate, body composition, food intake, GPS traces — is biometric data that deserves sovereignty.
|
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
|
## Stack
|
||||||
|
|
||||||
- Python 3.12, FastAPI, SQLAlchemy (async)
|
- Python 3.12, FastAPI, SQLAlchemy
|
||||||
- React 18, Vite
|
- React 18, Vite
|
||||||
- PostgreSQL 16
|
- PostgreSQL 16
|
||||||
- FastMCP (Streamable HTTP/SSE)
|
- FastMCP (Streamable HTTP/SSE)
|
||||||
- Docker Compose
|
- 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
|
## Contributing
|
||||||
|
|
||||||
Contributions welcome. Please open an issue to discuss before submitting a PR.
|
Contributions welcome. Open an issue to discuss before submitting a PR.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
.git
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
content/
|
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
|
|
@ -10,9 +10,8 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
secret_key: str = "change-me-in-production"
|
secret_key: str = "change-me-in-production"
|
||||||
crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 1440 # 24 hours
|
access_token_expire_minutes: int = 1440 # 24 hours
|
||||||
api_token: str = "" # MCP connector auth — generated by setup.sh
|
|
||||||
|
|
||||||
# Strava
|
# Strava
|
||||||
strava_client_id: str = ""
|
strava_client_id: str = ""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
@ -28,13 +27,6 @@ async def lifespan(app: FastAPI):
|
||||||
logger.error("Could not initialize database after 10 attempts — starting without tables")
|
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
|
# Run lightweight migrations for schema changes
|
||||||
try:
|
try:
|
||||||
await run_migrations()
|
await run_migrations()
|
||||||
|
|
@ -49,17 +41,14 @@ async def lifespan(app: FastAPI):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Resource seeding failed (non-fatal): {e}")
|
logger.warning(f"Resource seeding failed (non-fatal): {e}")
|
||||||
|
|
||||||
# Start background crawl queue scheduler (gated on CRAWL_ENABLED)
|
# Start background crawl queue scheduler
|
||||||
crawl_task = None
|
crawl_task = None
|
||||||
if _s.crawl_enabled:
|
try:
|
||||||
try:
|
from app.services.crawl_scheduler import crawl_queue_loop
|
||||||
from app.services.crawl_scheduler import crawl_queue_loop
|
crawl_task = asyncio.create_task(crawl_queue_loop())
|
||||||
crawl_task = asyncio.create_task(crawl_queue_loop())
|
logger.info("Crawl queue scheduler started")
|
||||||
logger.info("Crawl queue scheduler started")
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.warning(f"Crawl scheduler failed to start (non-fatal): {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")
|
logger.info("Fitness Rewards backend started")
|
||||||
yield
|
yield
|
||||||
|
|
@ -79,7 +68,7 @@ app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=False # Bearer tokens don't need credentials mode,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
@ -96,7 +85,6 @@ from app.routers.catalog import router as catalog_router
|
||||||
from app.routers.support import router as support_router
|
from app.routers.support import router as support_router
|
||||||
from app.routers.nutrition import router as nutrition_router
|
from app.routers.nutrition import router as nutrition_router
|
||||||
from app.routers.meal_plans import router as meal_plans_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(auth_router)
|
||||||
app.include_router(onboarding_router)
|
app.include_router(onboarding_router)
|
||||||
|
|
@ -110,7 +98,6 @@ app.include_router(support_router)
|
||||||
app.include_router(programs_router)
|
app.include_router(programs_router)
|
||||||
app.include_router(nutrition_router)
|
app.include_router(nutrition_router)
|
||||||
app.include_router(meal_plans_router)
|
app.include_router(meal_plans_router)
|
||||||
app.include_router(ai_chat_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
"""AI coaching chat endpoint — SSE streaming responses."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.utils.auth import get_current_user
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services import ai_provider
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(prefix="/ai", tags=["AI Coaching"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat")
|
|
||||||
async def chat_with_ai(
|
|
||||||
request: Request,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Stream an AI coaching response via SSE.
|
|
||||||
|
|
||||||
Body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]}
|
|
||||||
Response: text/event-stream with data chunks.
|
|
||||||
"""
|
|
||||||
body = await request.json()
|
|
||||||
message = body.get("message", "").strip()
|
|
||||||
history = body.get("history", [])
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
return {"error": "Message cannot be empty"}
|
|
||||||
|
|
||||||
# Validate history format
|
|
||||||
clean_history = []
|
|
||||||
for msg in history[-20:]: # Cap at last 20 messages
|
|
||||||
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant") and msg.get("content"):
|
|
||||||
clean_history.append({"role": msg["role"], "content": msg["content"][:4000]})
|
|
||||||
|
|
||||||
async def event_stream():
|
|
||||||
async for chunk in ai_provider.chat(user.id, message, clean_history, db):
|
|
||||||
yield f"data: {json.dumps({'text': chunk})}\n\n"
|
|
||||||
yield "data: [DONE]\n\n"
|
|
||||||
|
|
||||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def ai_status(
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Check which AI provider is configured."""
|
|
||||||
provider = await ai_provider.get_active_ai_provider(db)
|
|
||||||
if provider:
|
|
||||||
return {
|
|
||||||
"configured": True,
|
|
||||||
"provider": provider["name"],
|
|
||||||
"model": provider["model"],
|
|
||||||
}
|
|
||||||
return {"configured": False, "provider": None, "model": None}
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
@ -25,16 +26,11 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Username already taken")
|
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(
|
user = User(
|
||||||
username=req.username,
|
username=req.username,
|
||||||
display_name=req.display_name,
|
display_name=req.display_name,
|
||||||
password_hash=hash_password(req.password),
|
password_hash=hash_password(req.password),
|
||||||
email=req.email,
|
email=req.email,
|
||||||
is_admin=is_first_user,
|
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
@ -56,8 +52,9 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Registration failed: {e}", exc_info=True)
|
tb = traceback.format_exc()
|
||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
logger.error(f"Registration failed: {e}\n{tb}")
|
||||||
|
return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
|
|
@ -72,8 +69,9 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)])
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Login failed: {e}", exc_info=True)
|
tb = traceback.format_exc()
|
||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
logger.error(f"Login failed: {e}\n{tb}")
|
||||||
|
return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
|
|
@ -84,5 +82,4 @@ async def get_me(user: Annotated[User, Depends(get_current_user)]):
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"timezone": user.timezone,
|
"timezone": user.timezone,
|
||||||
"is_admin": getattr(user, "is_admin", False),
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,7 @@ async def integration_status(
|
||||||
# --- Strava ---
|
# --- Strava ---
|
||||||
@router.get("/strava/auth")
|
@router.get("/strava/auth")
|
||||||
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
|
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
|
||||||
from app.utils.auth import create_access_token
|
return {"auth_url": get_strava_auth_url(str(user.id))}
|
||||||
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")
|
@router.get("/strava/callback")
|
||||||
|
|
@ -51,14 +48,7 @@ async def strava_callback(
|
||||||
state: str = Query(...),
|
state: str = Query(...),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
from jose import JWTError, jwt as jose_jwt
|
user_id = uuid_mod.UUID(state)
|
||||||
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)
|
data = await exchange_strava_code(code)
|
||||||
|
|
||||||
# Upsert token
|
# Upsert token
|
||||||
|
|
@ -101,10 +91,7 @@ async def strava_sync(
|
||||||
# --- Polar ---
|
# --- Polar ---
|
||||||
@router.get("/polar/auth")
|
@router.get("/polar/auth")
|
||||||
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
|
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
|
||||||
from app.utils.auth import create_access_token
|
return {"auth_url": get_polar_auth_url(str(user.id))}
|
||||||
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")
|
@router.get("/polar/callback")
|
||||||
|
|
@ -113,14 +100,7 @@ async def polar_callback(
|
||||||
state: str = Query(...),
|
state: str = Query(...),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
from jose import JWTError, jwt as jose_jwt
|
user_id = uuid_mod.UUID(state)
|
||||||
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)
|
data = await exchange_polar_code(code)
|
||||||
|
|
||||||
# Register user with Polar
|
# Register user with Polar
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from app.config import settings
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/support", tags=["support"])
|
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
|
MAX_MESSAGES_PER_DAY = 10
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -278,8 +279,8 @@ async def get_unread_count(
|
||||||
# ── Admin Endpoints ────────────────────────────────────────────────────────
|
# ── Admin Endpoints ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def require_admin(user: User):
|
def require_admin(user: User):
|
||||||
"""Check that the user has admin privileges."""
|
"""Check that the user is the admin."""
|
||||||
if not getattr(user, "is_admin", False):
|
if user.username != ADMIN_USERNAME:
|
||||||
raise HTTPException(status_code=403, detail="Admin access required")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str = Field(min_length=3, max_length=50)
|
username: str
|
||||||
password: str = Field(min_length=8, max_length=128)
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str = Field(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
|
username: str
|
||||||
password: str = Field(min_length=8, max_length=128)
|
password: str
|
||||||
display_name: str = Field(min_length=1, max_length=100)
|
display_name: str
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
"""Multi-provider AI coaching service.
|
|
||||||
|
|
||||||
Two code paths:
|
|
||||||
- OpenAI-compatible: covers OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom
|
|
||||||
- Anthropic: covers Claude (different message format + auth header)
|
|
||||||
- Gemini: thin adapter for Google's generateContent API
|
|
||||||
|
|
||||||
System prompt is built from AGENT_GUIDE.md + live user context.
|
|
||||||
Chat history is ephemeral (not persisted).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.services.crypto import decrypt_value
|
|
||||||
from app.models.integration_config import IntegrationConfig
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
# AI provider presets — base_url and api_format for each named provider
|
|
||||||
AI_PRESETS = {
|
|
||||||
"openai": {"base_url": "https://api.openai.com/v1", "format": "openai", "default_model": "gpt-4o-mini"},
|
|
||||||
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "format": "openai", "default_model": "openai/gpt-4o-mini"},
|
|
||||||
"huggingface": {"base_url": "https://router.huggingface.co/v1", "format": "openai", "default_model": "meta-llama/Llama-3.3-70B-Instruct"},
|
|
||||||
"groq": {"base_url": "https://api.groq.com/openai/v1", "format": "openai", "default_model": "llama-3.3-70b-versatile"},
|
|
||||||
"ollama": {"base_url": "http://host.docker.internal:11434/v1", "format": "openai", "default_model": "llama3.1"},
|
|
||||||
"claude": {"base_url": "https://api.anthropic.com/v1", "format": "anthropic", "default_model": "claude-sonnet-4-6"},
|
|
||||||
"gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta", "format": "gemini", "default_model": "gemini-2.0-flash"},
|
|
||||||
"custom_ai": {"base_url": "", "format": "openai", "default_model": ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
# System prompt template — {context} is replaced with live user data
|
|
||||||
SYSTEM_PROMPT_TEMPLATE = """You are a fitness coach integrated with Diligence, a self-hosted fitness rewards platform.
|
|
||||||
|
|
||||||
Your role: motivate, guide, and help the user stay consistent with their health goals. Use the context below to personalize your responses.
|
|
||||||
|
|
||||||
CURRENT USER CONTEXT:
|
|
||||||
{context}
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Be encouraging but honest. Celebrate progress, address setbacks constructively.
|
|
||||||
- When the user asks to log a workout or food, confirm what you'll log and do it.
|
|
||||||
- Reference their program schedule when suggesting workouts.
|
|
||||||
- Respect their motivation type (from BREQ-2 profiling) to calibrate your tone.
|
|
||||||
- Keep responses concise — this is a mobile chat, not an essay.
|
|
||||||
- If the user hasn't earned enough points today, gently encourage activity.
|
|
||||||
- Never fabricate data — only reference what's in the context."""
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active_ai_provider(db: AsyncSession) -> dict | None:
|
|
||||||
"""Find the first configured AI provider."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(IntegrationConfig).where(
|
|
||||||
IntegrationConfig.provider.in_(list(AI_PRESETS.keys()))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
configs = result.scalars().all()
|
|
||||||
|
|
||||||
for config in configs:
|
|
||||||
provider_name = config.provider
|
|
||||||
try:
|
|
||||||
creds = json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
preset = AI_PRESETS.get(provider_name, AI_PRESETS["custom_ai"])
|
|
||||||
api_key = creds.get("api_key", "")
|
|
||||||
base_url = creds.get("endpoint_url", preset["base_url"]) or preset["base_url"]
|
|
||||||
model = creds.get("model", preset["default_model"]) or preset["default_model"]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": provider_name,
|
|
||||||
"format": preset["format"],
|
|
||||||
"base_url": base_url,
|
|
||||||
"api_key": api_key,
|
|
||||||
"model": model,
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def build_context(db: AsyncSession, user_id) -> str:
|
|
||||||
"""Build live context string from user's current state."""
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.profile import UserProfile
|
|
||||||
|
|
||||||
context_parts = []
|
|
||||||
|
|
||||||
# User profile
|
|
||||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
|
||||||
if user:
|
|
||||||
context_parts.append(f"Name: {user.display_name}")
|
|
||||||
context_parts.append(f"Timezone: {user.timezone}")
|
|
||||||
|
|
||||||
# Profile / motivation
|
|
||||||
profile = (await db.execute(select(UserProfile).where(UserProfile.user_id == user_id))).scalar_one_or_none()
|
|
||||||
if profile:
|
|
||||||
if profile.ttm_stage:
|
|
||||||
context_parts.append(f"Stage of change: {profile.ttm_stage}")
|
|
||||||
if profile.breq_profile:
|
|
||||||
context_parts.append(f"Motivation type: {profile.breq_profile}")
|
|
||||||
|
|
||||||
# Today's points (best effort)
|
|
||||||
try:
|
|
||||||
from app.services.points_engine import get_daily_summary
|
|
||||||
summary = await get_daily_summary(db, user_id)
|
|
||||||
context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}")
|
|
||||||
context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return "\n".join(context_parts) if context_parts else "No profile data available yet."
|
|
||||||
|
|
||||||
|
|
||||||
async def chat(
|
|
||||||
user_id,
|
|
||||||
message: str,
|
|
||||||
history: list[dict],
|
|
||||||
db: AsyncSession,
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Route chat to the configured AI provider with streaming."""
|
|
||||||
provider = await get_active_ai_provider(db)
|
|
||||||
if not provider:
|
|
||||||
yield "No AI provider configured. Go to Settings → Integrations to connect one (OpenAI, OpenRouter, Ollama, etc.)."
|
|
||||||
return
|
|
||||||
|
|
||||||
context = await build_context(db, user_id)
|
|
||||||
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if provider["format"] == "openai":
|
|
||||||
async for chunk in _call_openai_compat(
|
|
||||||
base_url=provider["base_url"],
|
|
||||||
api_key=provider["api_key"],
|
|
||||||
model=provider["model"],
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
history=history,
|
|
||||||
message=message,
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
elif provider["format"] == "anthropic":
|
|
||||||
async for chunk in _call_anthropic(
|
|
||||||
api_key=provider["api_key"],
|
|
||||||
model=provider["model"],
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
history=history,
|
|
||||||
message=message,
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
elif provider["format"] == "gemini":
|
|
||||||
async for chunk in _call_gemini(
|
|
||||||
api_key=provider["api_key"],
|
|
||||||
model=provider["model"],
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
history=history,
|
|
||||||
message=message,
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
yield f"AI provider error ({e.response.status_code}). Check your API key in Settings → Integrations."
|
|
||||||
except httpx.ConnectError:
|
|
||||||
yield f"Cannot reach {provider['name']}. Check your network or endpoint URL."
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"AI chat error: {e}")
|
|
||||||
yield "Something went wrong with the AI provider. Check Settings → Integrations."
|
|
||||||
|
|
||||||
|
|
||||||
async def _call_openai_compat(
|
|
||||||
base_url: str,
|
|
||||||
api_key: str,
|
|
||||||
model: str,
|
|
||||||
system_prompt: str,
|
|
||||||
history: list[dict],
|
|
||||||
message: str,
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Call any OpenAI-compatible /v1/chat/completions endpoint with streaming.
|
|
||||||
|
|
||||||
Covers: OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom.
|
|
||||||
"""
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
if api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
*history,
|
|
||||||
{"role": "user", "content": message},
|
|
||||||
],
|
|
||||||
"stream": True,
|
|
||||||
"max_tokens": 1024,
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
async with client.stream(
|
|
||||||
"POST",
|
|
||||||
f"{base_url.rstrip('/')}/chat/completions",
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
) as resp:
|
|
||||||
resp.raise_for_status()
|
|
||||||
async for line in resp.aiter_lines():
|
|
||||||
if line.startswith("data: ") and line.strip() != "data: [DONE]":
|
|
||||||
try:
|
|
||||||
chunk = json.loads(line[6:])
|
|
||||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
||||||
if content := delta.get("content"):
|
|
||||||
yield content
|
|
||||||
except (json.JSONDecodeError, IndexError, KeyError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
async def _call_anthropic(
|
|
||||||
api_key: str,
|
|
||||||
model: str,
|
|
||||||
system_prompt: str,
|
|
||||||
history: list[dict],
|
|
||||||
message: str,
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Call Anthropic's /v1/messages endpoint with streaming."""
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": api_key,
|
|
||||||
"anthropic-version": "2023-06-01",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Convert OpenAI-style history to Anthropic format
|
|
||||||
messages = []
|
|
||||||
for msg in history:
|
|
||||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
|
||||||
messages.append({"role": "user", "content": message})
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": model,
|
|
||||||
"system": system_prompt,
|
|
||||||
"messages": messages,
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"stream": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
async with client.stream(
|
|
||||||
"POST",
|
|
||||||
"https://api.anthropic.com/v1/messages",
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
) as resp:
|
|
||||||
resp.raise_for_status()
|
|
||||||
async for line in resp.aiter_lines():
|
|
||||||
if line.startswith("data: "):
|
|
||||||
try:
|
|
||||||
event = json.loads(line[6:])
|
|
||||||
if event.get("type") == "content_block_delta":
|
|
||||||
delta = event.get("delta", {})
|
|
||||||
if text := delta.get("text"):
|
|
||||||
yield text
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
async def _call_gemini(
|
|
||||||
api_key: str,
|
|
||||||
model: str,
|
|
||||||
system_prompt: str,
|
|
||||||
history: list[dict],
|
|
||||||
message: str,
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Call Google Gemini's generateContent endpoint with streaming."""
|
|
||||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent"
|
|
||||||
|
|
||||||
# Build Gemini content format
|
|
||||||
contents = []
|
|
||||||
# System instruction goes as first user/model exchange
|
|
||||||
if system_prompt:
|
|
||||||
contents.append({"role": "user", "parts": [{"text": f"[System instructions]\n{system_prompt}"}]})
|
|
||||||
contents.append({"role": "model", "parts": [{"text": "Understood. I'll follow these instructions."}]})
|
|
||||||
|
|
||||||
for msg in history:
|
|
||||||
role = "model" if msg["role"] == "assistant" else "user"
|
|
||||||
contents.append({"role": role, "parts": [{"text": msg["content"]}]})
|
|
||||||
contents.append({"role": "user", "parts": [{"text": message}]})
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"contents": contents,
|
|
||||||
"generationConfig": {"maxOutputTokens": 1024},
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
async with client.stream(
|
|
||||||
"POST",
|
|
||||||
url,
|
|
||||||
json=payload,
|
|
||||||
params={"key": api_key, "alt": "sse"},
|
|
||||||
) as resp:
|
|
||||||
resp.raise_for_status()
|
|
||||||
async for line in resp.aiter_lines():
|
|
||||||
if line.startswith("data: "):
|
|
||||||
try:
|
|
||||||
chunk = json.loads(line[6:])
|
|
||||||
candidates = chunk.get("candidates", [])
|
|
||||||
if candidates:
|
|
||||||
parts = candidates[0].get("content", {}).get("parts", [])
|
|
||||||
for part in parts:
|
|
||||||
if text := part.get("text"):
|
|
||||||
yield text
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
continue
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
"""Base class for device activity sync services.
|
|
||||||
|
|
||||||
All device sync implementations (garmin_sync, whoop_sync, oura_sync)
|
|
||||||
inherit from this class. The pattern mirrors the existing strava_sync
|
|
||||||
and polar_sync services but adds webhook support and a standard
|
|
||||||
interface for the generic webhook receiver endpoint.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.models.oauth import OAuthToken
|
|
||||||
from app.models.integration_config import IntegrationConfig
|
|
||||||
from app.services.crypto import decrypt_value
|
|
||||||
from app.services.points_engine import log_activity_with_points
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceSyncBase(abc.ABC):
|
|
||||||
"""Abstract base for all device sync services.
|
|
||||||
|
|
||||||
Subclasses implement the provider-specific OAuth flow, API calls,
|
|
||||||
and data mapping. The base handles token storage/refresh patterns
|
|
||||||
and the common activity logging pipeline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
PROVIDER: str # e.g. "garmin", "whoop", "oura"
|
|
||||||
API_BASE: str # e.g. "https://apis.garmin.com"
|
|
||||||
|
|
||||||
# ── OAuth ──
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def get_auth_url(self, user_id: uuid.UUID, db: AsyncSession) -> str:
|
|
||||||
"""Generate the OAuth authorization URL for this provider."""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def handle_callback(
|
|
||||||
self, code: str, state: str, db: AsyncSession
|
|
||||||
) -> dict:
|
|
||||||
"""Exchange authorization code for tokens, store them.
|
|
||||||
|
|
||||||
Returns: {"success": True, "provider": "garmin"}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def _refresh_token(self, token: OAuthToken, db: AsyncSession) -> OAuthToken:
|
|
||||||
"""Provider-specific token refresh logic."""
|
|
||||||
|
|
||||||
# ── Sync ──
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def sync_activities(
|
|
||||||
self, user_id: uuid.UUID, db: AsyncSession
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Pull new activities from the provider and award points.
|
|
||||||
|
|
||||||
Returns list of imported activity dicts with points_earned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ── Webhooks ──
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def validate_webhook(self, request_body: bytes, headers: dict) -> bool:
|
|
||||||
"""Validate an incoming webhook signature.
|
|
||||||
|
|
||||||
Returns True if the webhook is authentic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def handle_webhook(
|
|
||||||
self, payload: dict, db: AsyncSession
|
|
||||||
) -> dict:
|
|
||||||
"""Process an incoming webhook notification.
|
|
||||||
|
|
||||||
Typically identifies the user and triggers sync_activities().
|
|
||||||
Returns: {"processed": True, "activities_imported": N}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ── Helpers (shared) ──
|
|
||||||
|
|
||||||
async def get_valid_token(
|
|
||||||
self, db: AsyncSession, user_id: uuid.UUID
|
|
||||||
) -> OAuthToken | None:
|
|
||||||
"""Get a valid OAuth token, refreshing if expired."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(OAuthToken).where(
|
|
||||||
OAuthToken.user_id == user_id,
|
|
||||||
OAuthToken.provider == self.PROVIDER,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
token = result.scalar_one_or_none()
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if token.expires_at and token.expires_at < datetime.now(timezone.utc):
|
|
||||||
try:
|
|
||||||
token = await self._refresh_token(token, db)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Token refresh failed for {self.PROVIDER}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return token
|
|
||||||
|
|
||||||
async def get_credentials(self, db: AsyncSession) -> dict | None:
|
|
||||||
"""Get decrypted OAuth credentials from integration_configs."""
|
|
||||||
import json
|
|
||||||
result = await db.execute(
|
|
||||||
select(IntegrationConfig).where(
|
|
||||||
IntegrationConfig.provider == self.PROVIDER
|
|
||||||
)
|
|
||||||
)
|
|
||||||
config = result.scalar_one_or_none()
|
|
||||||
if not config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to decrypt {self.PROVIDER} credentials: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def import_activity(
|
|
||||||
self,
|
|
||||||
db: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
category: str = "workout",
|
|
||||||
activity_date: Any,
|
|
||||||
duration_minutes: int | None = None,
|
|
||||||
external_id: str | None = None,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Import a single activity using the standard points pipeline.
|
|
||||||
|
|
||||||
Deduplicates by (user_id, provider, external_id).
|
|
||||||
"""
|
|
||||||
from app.models.activity import ActivityLog
|
|
||||||
|
|
||||||
if external_id:
|
|
||||||
existing = await db.execute(
|
|
||||||
select(ActivityLog).where(
|
|
||||||
ActivityLog.user_id == user_id,
|
|
||||||
ActivityLog.source == self.PROVIDER,
|
|
||||||
ActivityLog.external_id == external_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
return {"skipped": True, "external_id": external_id}
|
|
||||||
|
|
||||||
entry = await log_activity_with_points(
|
|
||||||
db=db,
|
|
||||||
user_id=user_id,
|
|
||||||
category=category,
|
|
||||||
activity_date=activity_date,
|
|
||||||
title=title,
|
|
||||||
duration_minutes=duration_minutes,
|
|
||||||
source=self.PROVIDER,
|
|
||||||
external_id=external_id,
|
|
||||||
metadata=metadata or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(entry.id),
|
|
||||||
"title": entry.title,
|
|
||||||
"points_earned": entry.points_earned,
|
|
||||||
"activity_date": str(activity_date),
|
|
||||||
}
|
|
||||||
|
|
@ -2,213 +2,65 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROVIDER_REGISTRY: dict[str, dict] = {
|
PROVIDER_REGISTRY: dict[str, dict] = {
|
||||||
|
|
||||||
# ═══ Device Integrations ═══
|
|
||||||
|
|
||||||
"strava": {
|
"strava": {
|
||||||
"name": "Strava",
|
"name": "Strava",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
"auth_url": "https://www.strava.com/oauth/authorize",
|
"auth_url": "https://www.strava.com/oauth/authorize",
|
||||||
"token_url": "https://www.strava.com/oauth/token",
|
"token_url": "https://www.strava.com/oauth/token",
|
||||||
"scopes": "read,activity:read_all",
|
"scopes": "read,activity:read_all",
|
||||||
"help_url": "https://developers.strava.com/",
|
"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",
|
"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": {
|
"polar": {
|
||||||
"name": "Polar",
|
"name": "Polar",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
"auth_url": "https://flow.polar.com/oauth2/authorization",
|
"auth_url": "https://flow.polar.com/oauth2/authorization",
|
||||||
"token_url": "https://polarremote.com/v2/oauth2/token",
|
"token_url": "https://polarremote.com/v2/oauth2/token",
|
||||||
"help_url": "https://admin.polaraccesslink.com/",
|
"help_url": "https://admin.polaraccesslink.com/",
|
||||||
"help_text": "Register at admin.polaraccesslink.com",
|
"help_text": "Register at admin.polaraccesslink.com",
|
||||||
"has_webhook": False,
|
|
||||||
"sync_service": "polar_sync",
|
|
||||||
},
|
},
|
||||||
"garmin": {
|
"garmin": {
|
||||||
"name": "Garmin Connect",
|
"name": "Garmin Connect",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
"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_url": "https://developer.garmin.com/gc-developer-program/",
|
||||||
"help_text": "Apply at developer.garmin.com — approval ~2 business days, requires a business entity.",
|
"help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks",
|
||||||
"has_webhook": True,
|
|
||||||
"sync_service": "garmin_sync",
|
|
||||||
},
|
|
||||||
"whoop": {
|
|
||||||
"name": "WHOOP",
|
|
||||||
"type": "oauth2",
|
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
|
||||||
"auth_url": "https://api.prod.whoop.com/oauth/oauth2/auth",
|
|
||||||
"token_url": "https://api.prod.whoop.com/oauth/oauth2/token",
|
|
||||||
"scopes": "read:workout,read:recovery,read:sleep,read:profile,offline",
|
|
||||||
"help_url": "https://developer.whoop.com/",
|
|
||||||
"help_text": "Self-serve at developer.whoop.com. Requires WHOOP device. <10 users without app approval.",
|
|
||||||
"has_webhook": True,
|
|
||||||
"sync_service": "whoop_sync",
|
|
||||||
},
|
|
||||||
"oura": {
|
|
||||||
"name": "Oura Ring",
|
|
||||||
"type": "oauth2",
|
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
|
||||||
"auth_url": "https://cloud.ouraring.com/oauth/authorize",
|
|
||||||
"token_url": "https://api.ouraring.com/oauth/token",
|
|
||||||
"scopes": "daily,workout,heartrate,session",
|
|
||||||
"help_url": "https://cloud.ouraring.com/v2/docs",
|
|
||||||
"help_text": "Register at cloud.ouraring.com — self-serve, immediate access.",
|
|
||||||
"has_webhook": True,
|
|
||||||
"sync_service": "oura_sync",
|
|
||||||
},
|
|
||||||
"coros": {
|
|
||||||
"name": "COROS (via MCP)",
|
|
||||||
"type": "mcp_bridge",
|
|
||||||
"category": "device",
|
|
||||||
"fields": [],
|
|
||||||
"help_url": "https://support.coros.com/hc/en-us/articles/17085887816340",
|
|
||||||
"help_text": "COROS has an official MCP server. Add it alongside Diligence in your AI agent config.",
|
|
||||||
"has_webhook": False,
|
|
||||||
"sync_service": None,
|
|
||||||
},
|
},
|
||||||
"fitbit": {
|
"fitbit": {
|
||||||
"name": "Fitbit",
|
"name": "Fitbit",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
"auth_url": "https://www.fitbit.com/oauth2/authorize",
|
"auth_url": "https://www.fitbit.com/oauth2/authorize",
|
||||||
"token_url": "https://api.fitbit.com/oauth2/token",
|
"token_url": "https://api.fitbit.com/oauth2/token",
|
||||||
"help_url": "https://dev.fitbit.com/",
|
"help_url": "https://dev.fitbit.com/",
|
||||||
"help_text": "Register at dev.fitbit.com via Google Cloud Console.",
|
"help_text": "Register at dev.fitbit.com",
|
||||||
"has_webhook": True,
|
|
||||||
"sync_service": None,
|
|
||||||
},
|
},
|
||||||
"withings": {
|
"withings": {
|
||||||
"name": "Withings",
|
"name": "Withings",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
"help_url": "https://developer.withings.com/",
|
"help_url": "https://developer.withings.com/",
|
||||||
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure.",
|
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure",
|
||||||
"has_webhook": False,
|
|
||||||
"sync_service": None,
|
|
||||||
},
|
},
|
||||||
"suunto": {
|
"whoop": {
|
||||||
"name": "Suunto",
|
"name": "WHOOP",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
"category": "device",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
"help_url": "https://apizone.suunto.com/",
|
"help_url": "https://developer.whoop.com/",
|
||||||
"help_text": "Apply at Suunto developer portal — contract required, ~2 week response.",
|
"help_text": "Apply at developer.whoop.com — recovery, strain, sleep",
|
||||||
"has_webhook": True,
|
|
||||||
"sync_service": None,
|
|
||||||
},
|
},
|
||||||
|
"oura": {
|
||||||
# ═══ AI Coaching Providers ═══
|
"name": "Oura Ring",
|
||||||
|
"type": "oauth2",
|
||||||
"openai": {
|
"fields": ["client_id", "client_secret"],
|
||||||
"name": "OpenAI",
|
"help_url": "https://cloud.ouraring.com/v2/docs",
|
||||||
"type": "api_key",
|
"help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV",
|
||||||
"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": {
|
"usda": {
|
||||||
"name": "USDA FoodData Central",
|
"name": "USDA FoodData Central",
|
||||||
"type": "api_key",
|
"type": "api_key",
|
||||||
"category": "nutrition",
|
|
||||||
"fields": ["api_key"],
|
"fields": ["api_key"],
|
||||||
"help_url": "https://fdc.nal.usda.gov/api-key-signup",
|
"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.",
|
"help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.",
|
||||||
|
|
@ -216,20 +68,22 @@ PROVIDER_REGISTRY: dict[str, dict] = {
|
||||||
"nutritionix": {
|
"nutritionix": {
|
||||||
"name": "Nutritionix",
|
"name": "Nutritionix",
|
||||||
"type": "api_key",
|
"type": "api_key",
|
||||||
"category": "nutrition",
|
|
||||||
"fields": ["app_id", "api_key"],
|
"fields": ["app_id", "api_key"],
|
||||||
"help_url": "https://developer.nutritionix.com/",
|
"help_url": "https://developer.nutritionix.com/",
|
||||||
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
|
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
|
||||||
},
|
},
|
||||||
|
"groq": {
|
||||||
# ═══ Notifications ═══
|
"name": "Groq (Program Research)",
|
||||||
|
"type": "api_key",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"help_url": "https://console.groq.com/keys",
|
||||||
|
"help_text": "Free API key. Enables AI-powered workout program extraction.",
|
||||||
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"name": "Telegram Notifications",
|
"name": "Telegram Notifications",
|
||||||
"type": "api_key",
|
"type": "api_key",
|
||||||
"category": "notifications",
|
|
||||||
"fields": ["bot_token", "chat_id"],
|
"fields": ["bot_token", "chat_id"],
|
||||||
"help_url": "https://core.telegram.org/bots#botfather",
|
"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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ from app.config import get_settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ALGORITHM = "HS256" # Hardcoded — not configurable for security
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
|
|
@ -28,11 +27,11 @@ def verify_password(plain: str, hashed: str) -> bool:
|
||||||
def create_access_token(user_id: str, expires_delta: timedelta | None = None) -> str:
|
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))
|
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
|
||||||
payload = {"sub": user_id, "exp": expire}
|
payload = {"sub": user_id, "exp": expire}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
token: Annotated[str | None, Depends(oauth2_scheme)],
|
token: Annotated[str, Depends(oauth2_scheme)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
|
|
@ -40,30 +39,6 @@ async def get_current_user(
|
||||||
detail="Invalid authentication credentials",
|
detail="Invalid authentication credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
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:
|
try:
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
user_id: str = payload.get("sub")
|
user_id: str = payload.get("sub")
|
||||||
|
|
@ -72,6 +47,7 @@ async def get_current_user(
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
|
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ services:
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -20,7 +18,6 @@ services:
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=${BASE_URL:-http://localhost}
|
- BASE_URL=${BASE_URL:-http://localhost}
|
||||||
- API_TOKEN=${API_TOKEN:-}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
fitness-db:
|
fitness-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -36,7 +33,6 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- FITNESS_API_URL=http://backend:8000
|
- FITNESS_API_URL=http://backend:8000
|
||||||
- API_TOKEN=${API_TOKEN:-}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { Routes, Route, Navigate, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { hasToken, clearToken, api } from './api'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import LogActivity from './pages/LogActivity'
|
||||||
|
import LogFood from './pages/LogFood'
|
||||||
|
import Nutrition from './pages/Nutrition'
|
||||||
|
import Rewards from './pages/Rewards'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
import Onboarding from './pages/Onboarding'
|
||||||
|
import WeekView from './pages/WeekView'
|
||||||
|
import Welcome from './pages/Welcome'
|
||||||
|
import SettingsIntegrations from './pages/SettingsIntegrations'
|
||||||
|
import MealPlan from './pages/MealPlan'
|
||||||
|
import ProgramSearch from './pages/ProgramSearch'
|
||||||
|
import ProgramDetail from './pages/ProgramDetail'
|
||||||
|
import CatalogDetail from './pages/CatalogDetail'
|
||||||
|
import Support from './pages/Support'
|
||||||
|
import SupportAdmin from './pages/SupportAdmin'
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
if (!hasToken()) return <Navigate to="/login" />
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpButton() {
|
||||||
|
const [unread, setUnread] = useState(0)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Don't show on login/onboarding/support pages
|
||||||
|
const hidden = ['/login', '/onboarding', '/support'].some(p => location.pathname.startsWith(p))
|
||||||
|
if (hidden) return null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasToken()) return
|
||||||
|
api.getUnreadCount()
|
||||||
|
.then(d => setUnread(d.unread || 0))
|
||||||
|
.catch(() => {})
|
||||||
|
// Poll every 60s for new replies
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
api.getUnreadCount()
|
||||||
|
.then(d => setUnread(d.unread || 0))
|
||||||
|
.catch(() => {})
|
||||||
|
}, 60000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/support')}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: '12px', right: '12px', zIndex: 90,
|
||||||
|
width: '40px', height: '40px', borderRadius: '50%',
|
||||||
|
background: 'var(--card)', boxShadow: 'var(--shadow-2)',
|
||||||
|
border: '1px solid var(--divider)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '1.1rem', padding: 0, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
{unread > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: '-4px', right: '-4px',
|
||||||
|
background: 'var(--red)', color: '#fff', fontSize: '0.6rem', fontWeight: 800,
|
||||||
|
width: '18px', height: '18px', borderRadius: '50%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: '2px solid var(--bg)',
|
||||||
|
}}>
|
||||||
|
{unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavBar() {
|
||||||
|
return (
|
||||||
|
<nav className="nav-bar">
|
||||||
|
<NavLink to="/" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<span className="nav-icon">🏠</span> Home
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/log" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<span className="nav-icon">💪</span> Log
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/nutrition" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<span className="nav-icon">🥑</span> Keto
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/programs" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<span className="nav-icon">📋</span> Programs
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<span className="nav-icon">⚙️</span> More
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasToken() && <HelpButton />}
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/log" element={<ProtectedRoute><LogActivity /></ProtectedRoute>} />
|
||||||
|
<Route path="/food" element={<ProtectedRoute><LogFood /></ProtectedRoute>} />
|
||||||
|
<Route path="/nutrition" element={<ProtectedRoute><Nutrition /></ProtectedRoute>} />
|
||||||
|
<Route path="/programs" element={<ProtectedRoute><ProgramSearch /></ProtectedRoute>} />
|
||||||
|
<Route path="/programs/:id" element={<ProtectedRoute><ProgramDetail /></ProtectedRoute>} />
|
||||||
|
<Route path="/catalog/:id" element={<ProtectedRoute><CatalogDetail /></ProtectedRoute>} />
|
||||||
|
<Route path="/rewards" element={<ProtectedRoute><Rewards /></ProtectedRoute>} />
|
||||||
|
<Route path="/week" element={<ProtectedRoute><WeekView /></ProtectedRoute>} />
|
||||||
|
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
|
<Route path="/support" element={<ProtectedRoute><Support /></ProtectedRoute>} />
|
||||||
|
<Route path="/support/admin" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
|
||||||
|
<Route path="/support/admin/:threadId" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
|
||||||
|
</Routes>
|
||||||
|
{hasToken() && <NavBar />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -139,10 +139,6 @@ export const api = {
|
||||||
listProviders: () => request('/integrations/providers'),
|
listProviders: () => request('/integrations/providers'),
|
||||||
configureIntegration: (provider, credentials) =>
|
configureIntegration: (provider, credentials) =>
|
||||||
request('/integrations/configure', { method: 'POST', body: JSON.stringify({ 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
|
// Resources
|
||||||
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,51 @@
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* === DW Brand — Light === */
|
/* === Solar Momentum Palette === */
|
||||||
--accent: #2952CC;
|
--orange: #FF5722;
|
||||||
--accent-dark: #1e3fa8;
|
--orange-dark: #E64A19;
|
||||||
--accent-light: #4A78E0;
|
--orange-light: #FF8A65;
|
||||||
--accent-bg: #edf1fc;
|
--orange-ghost: rgba(255, 87, 34, 0.06);
|
||||||
--accent-glow: rgba(41, 82, 204, 0.18);
|
--orange-glow: rgba(255, 87, 34, 0.18);
|
||||||
|
|
||||||
--green: #0d7d5a;
|
--green: #00C853;
|
||||||
--green-dark: #065c40;
|
--green-dark: #00A844;
|
||||||
--green-light: #30C090;
|
--green-light: #69F0AE;
|
||||||
--green-bg: rgba(13, 125, 90, 0.08);
|
--green-ghost: rgba(0, 200, 83, 0.06);
|
||||||
|
|
||||||
--red: #C62828;
|
--blue: #2979FF;
|
||||||
--red-bg: rgba(198, 40, 40, 0.06);
|
--blue-ghost: rgba(41, 121, 255, 0.06);
|
||||||
|
--purple: #7C4DFF;
|
||||||
|
--purple-ghost: rgba(124, 77, 255, 0.06);
|
||||||
|
|
||||||
--amber: #b07800;
|
--red: #FF1744;
|
||||||
--amber-bg: rgba(176, 120, 0, 0.08);
|
--red-ghost: rgba(255, 23, 68, 0.06);
|
||||||
|
--amber: #FFB300;
|
||||||
|
|
||||||
--text: #1a1f2e;
|
--text: #1B1B2F;
|
||||||
--text-2: #3d4660;
|
--text-2: #4A4A68;
|
||||||
--text-3: #6b7490;
|
--text-3: #8888A4;
|
||||||
--text-inv: #FFFFFF;
|
--text-inv: #FFFFFF;
|
||||||
|
|
||||||
--bg: #fafbfe;
|
--bg: #F5F3EF;
|
||||||
--bg-warm: linear-gradient(170deg, #f0f2fa 0%, #fafbfe 50%, #f5f7fd 100%);
|
--bg-warm: linear-gradient(170deg, #FFF3E8 0%, #F5F3EF 50%, #EDF2FF 100%);
|
||||||
--card: #FFFFFF;
|
--card: #FFFFFF;
|
||||||
--card-border: rgba(216, 220, 232, 0.6);
|
--card-border: rgba(0,0,0,0.04);
|
||||||
--divider: rgba(216, 220, 232, 0.8);
|
--divider: rgba(0,0,0,0.06);
|
||||||
|
|
||||||
--shadow-1: 0 1px 3px rgba(26, 31, 46, 0.06);
|
--shadow-1: 0 1px 3px rgba(27,27,47,0.05);
|
||||||
--shadow-2: 0 4px 16px rgba(26, 31, 46, 0.08);
|
--shadow-2: 0 4px 16px rgba(27,27,47,0.08);
|
||||||
--shadow-3: 0 12px 40px rgba(26, 31, 46, 0.12);
|
--shadow-3: 0 12px 40px rgba(27,27,47,0.12);
|
||||||
--shadow-accent: 0 4px 20px rgba(41, 82, 204, 0.20);
|
--shadow-orange: 0 4px 20px rgba(255,87,34,0.25);
|
||||||
--shadow-green: 0 4px 20px rgba(13, 125, 90, 0.20);
|
--shadow-green: 0 4px 20px rgba(0,200,83,0.25);
|
||||||
|
|
||||||
--r: 8px;
|
--r: 14px;
|
||||||
--r-sm: 6px;
|
--r-sm: 10px;
|
||||||
--r-lg: 12px;
|
--r-lg: 20px;
|
||||||
--r-full: 999px;
|
--r-full: 999px;
|
||||||
|
|
||||||
--font: 'Instrument Sans', -apple-system, sans-serif;
|
--font: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
--font-display: 'Instrument Sans', -apple-system, sans-serif;
|
--font-display: 'Outfit', system-ui, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Reset === */
|
/* === Reset === */
|
||||||
|
|
@ -62,7 +64,7 @@ body {
|
||||||
/* === Typography === */
|
/* === Typography === */
|
||||||
h1, h2, h3 { font-family: var(--font-display); letter-spacing: -0.02em; line-height: 1.2; }
|
h1, h2, h3 { font-family: var(--font-display); letter-spacing: -0.02em; line-height: 1.2; }
|
||||||
|
|
||||||
a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
a { color: var(--blue); text-decoration: none; font-weight: 600; }
|
||||||
a:hover { opacity: 0.8; }
|
a:hover { opacity: 0.8; }
|
||||||
|
|
||||||
/* === Buttons === */
|
/* === Buttons === */
|
||||||
|
|
@ -81,11 +83,11 @@ button {
|
||||||
button:active { transform: scale(0.97); }
|
button:active { transform: scale(0.97); }
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--orange);
|
||||||
color: var(--text-inv);
|
color: var(--text-inv);
|
||||||
box-shadow: var(--shadow-accent);
|
box-shadow: var(--shadow-orange);
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: var(--accent-dark); }
|
.btn-primary:hover { background: var(--orange-dark); }
|
||||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
|
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
|
|
@ -99,14 +101,14 @@ button:active { transform: scale(0.97); }
|
||||||
border: 2px solid var(--divider);
|
border: 2px solid var(--divider);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
.btn-outline:hover { border-color: var(--orange); color: var(--orange); }
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
}
|
}
|
||||||
.btn-ghost:hover { background: var(--accent-bg); color: var(--accent); }
|
.btn-ghost:hover { background: var(--orange-ghost); color: var(--orange); }
|
||||||
|
|
||||||
.btn-danger { background: var(--red); color: var(--text-inv); }
|
.btn-danger { background: var(--red); color: var(--text-inv); }
|
||||||
.btn-sm { padding: 8px 16px; font-size: 0.82rem; border-radius: var(--r-sm); }
|
.btn-sm { padding: 8px 16px; font-size: 0.82rem; border-radius: var(--r-sm); }
|
||||||
|
|
@ -127,8 +129,8 @@ input, select, textarea {
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
input:focus, select:focus, textarea:focus {
|
input:focus, select:focus, textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--orange);
|
||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
box-shadow: 0 0 0 3px var(--orange-glow);
|
||||||
}
|
}
|
||||||
input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 400; }
|
input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 400; }
|
||||||
|
|
||||||
|
|
@ -155,7 +157,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
.page-title {
|
.page-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 800;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,9 +181,8 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
|
|
||||||
/* === Section Title (small label) === */
|
/* === Section Title (small label) === */
|
||||||
.section-label {
|
.section-label {
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
|
|
@ -202,7 +203,6 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
.checklist-check { font-size: 1.15rem; min-width: 26px; }
|
.checklist-check { font-size: 1.15rem; min-width: 26px; }
|
||||||
.checklist-points {
|
.checklist-points {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
|
|
@ -239,13 +239,13 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.nav-item.active { color: var(--accent); }
|
.nav-item.active { color: var(--orange); }
|
||||||
.nav-item.active::before {
|
.nav-item.active::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px; left: 50%; transform: translateX(-50%);
|
top: -4px; left: 50%; transform: translateX(-50%);
|
||||||
width: 20px; height: 3px;
|
width: 20px; height: 3px;
|
||||||
background: var(--accent);
|
background: var(--orange);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.nav-icon { font-size: 1.2rem; margin-bottom: 1px; }
|
.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;
|
transition: all 0.2s;
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
}
|
}
|
||||||
.option-btn:hover { border-color: var(--accent-light); transform: translateY(-2px); box-shadow: var(--shadow-2); }
|
.option-btn:hover { border-color: var(--orange-light); transform: translateY(-2px); box-shadow: var(--shadow-2); }
|
||||||
.option-btn.selected {
|
.option-btn.selected {
|
||||||
border-color: var(--accent);
|
border-color: var(--orange);
|
||||||
background: var(--accent-bg);
|
background: var(--orange-ghost);
|
||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
box-shadow: 0 0 0 3px var(--orange-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Chips === */
|
/* === Chips === */
|
||||||
|
|
@ -299,10 +299,10 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.chip:hover { border-color: var(--accent-light); }
|
.chip:hover { border-color: var(--orange-light); }
|
||||||
.chip.selected {
|
.chip.selected {
|
||||||
border-color: var(--accent);
|
border-color: var(--orange);
|
||||||
background: var(--accent);
|
background: var(--orange);
|
||||||
color: var(--text-inv);
|
color: var(--text-inv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,19 +330,19 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 2px solid var(--divider);
|
border: 2px solid var(--divider);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 600;
|
font-weight: 800;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
}
|
}
|
||||||
.likert-btn:hover { border-color: var(--accent); transform: scale(1.1); }
|
.likert-btn:hover { border-color: var(--orange); transform: scale(1.1); }
|
||||||
.likert-btn.selected {
|
.likert-btn.selected {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: var(--accent);
|
background: var(--orange);
|
||||||
color: var(--text-inv);
|
color: var(--text-inv);
|
||||||
box-shadow: var(--shadow-accent);
|
box-shadow: var(--shadow-orange);
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
.likert-labels {
|
.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);
|
border: 2px solid rgba(255,87,34,0.15);
|
||||||
}
|
}
|
||||||
.gate-pts {
|
.gate-pts {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-display);
|
||||||
font-size: 2.8rem;
|
font-size: 2.8rem;
|
||||||
font-weight: 700;
|
font-weight: 900;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
|
|
@ -384,7 +384,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
.meal-section { margin-bottom: 18px; }
|
.meal-section { margin-bottom: 18px; }
|
||||||
.meal-title {
|
.meal-title {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
|
|
@ -433,106 +433,6 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
.gate-banner { animation: popIn 0.35s cubic-bezier(.4,0,.2,1) both; }
|
.gate-banner { animation: popIn 0.35s cubic-bezier(.4,0,.2,1) both; }
|
||||||
.gate-pts { animation: countUp 0.4s ease-out 0.15s 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 === */
|
/* === Scrollbar === */
|
||||||
::-webkit-scrollbar { width: 4px; }
|
::-webkit-scrollbar { width: 4px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { api } from '../api'
|
||||||
|
|
||||||
const DIFFICULTY_COLORS = {
|
const DIFFICULTY_COLORS = {
|
||||||
beginner: 'var(--green)',
|
beginner: 'var(--green)',
|
||||||
intermediate: 'var(--accent)',
|
intermediate: 'var(--blue)',
|
||||||
advanced: 'var(--accent-light)',
|
advanced: 'var(--purple)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_ICONS = {
|
const CATEGORY_ICONS = {
|
||||||
|
|
@ -17,8 +17,8 @@ const CATEGORY_ICONS = {
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
pending: { label: 'Queued', color: 'var(--amber)' },
|
pending: { label: 'Queued', color: 'var(--amber)' },
|
||||||
crawling: { label: 'Fetching...', color: 'var(--accent)' },
|
crawling: { label: 'Fetching...', color: 'var(--blue)' },
|
||||||
extracting: { label: 'Analyzing...', color: 'var(--accent-light)' },
|
extracting: { label: 'Analyzing...', color: 'var(--purple)' },
|
||||||
ready: { label: 'Ready', color: 'var(--green)' },
|
ready: { label: 'Ready', color: 'var(--green)' },
|
||||||
failed: { label: 'Failed', color: 'var(--red)' },
|
failed: { label: 'Failed', color: 'var(--red)' },
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +72,7 @@ export default function CatalogDetail() {
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
<p style={{ color: 'var(--text-2)', marginBottom: '1rem' }}>Program not found.</p>
|
<p style={{ color: 'var(--text-2)', marginBottom: '1rem' }}>Program not found.</p>
|
||||||
<button onClick={() => navigate('/programs')} style={{
|
<button onClick={() => navigate('/programs')} style={{
|
||||||
background: 'var(--accent)', color: '#fff', padding: '10px 20px',
|
background: 'var(--blue)', color: '#fff', padding: '10px 20px',
|
||||||
}}>← Back to Programs</button>
|
}}>← Back to Programs</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -144,7 +144,7 @@ export default function CatalogDetail() {
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block', marginTop: '14px', fontSize: '0.78rem',
|
display: 'inline-block', marginTop: '14px', fontSize: '0.78rem',
|
||||||
color: 'var(--accent)', textDecoration: 'none',
|
color: 'var(--blue)', textDecoration: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🔗 Original source ↗
|
🔗 Original source ↗
|
||||||
|
|
@ -157,7 +157,7 @@ export default function CatalogDetail() {
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px 16px', borderRadius: 'var(--r-sm)', marginBottom: '1rem',
|
padding: '12px 16px', borderRadius: 'var(--r-sm)', marginBottom: '1rem',
|
||||||
fontSize: '0.85rem', fontWeight: 600,
|
fontSize: '0.85rem', fontWeight: 600,
|
||||||
background: message.type === 'error' ? 'var(--red-ghost)' : 'var(--green-bg)',
|
background: message.type === 'error' ? 'var(--red-ghost)' : 'var(--green-ghost)',
|
||||||
color: message.type === 'error' ? 'var(--red)' : 'var(--green-dark)',
|
color: message.type === 'error' ? 'var(--red)' : 'var(--green-dark)',
|
||||||
}}>
|
}}>
|
||||||
{message.text}
|
{message.text}
|
||||||
|
|
@ -183,9 +183,9 @@ export default function CatalogDetail() {
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/programs/${activeRecord.id}`)}
|
onClick={() => navigate(`/programs/${activeRecord.id}`)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', background: 'var(--accent)', color: '#fff',
|
width: '100%', background: 'var(--orange)', color: '#fff',
|
||||||
padding: '14px', fontSize: '0.95rem', fontWeight: 800,
|
padding: '14px', fontSize: '0.95rem', fontWeight: 800,
|
||||||
marginBottom: '1.25rem', boxShadow: 'var(--shadow-accent)',
|
marginBottom: '1.25rem', boxShadow: 'var(--shadow-orange)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✓ Active — Open Your Program
|
✓ Active — Open Your Program
|
||||||
|
|
@ -194,8 +194,8 @@ export default function CatalogDetail() {
|
||||||
|
|
||||||
{program.crawl_status !== 'ready' && (
|
{program.crawl_status !== 'ready' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '14px', borderRadius: 'var(--r)', background: 'var(--accent-bg)',
|
padding: '14px', borderRadius: 'var(--r)', background: 'var(--blue-ghost)',
|
||||||
color: 'var(--accent)', fontSize: '0.85rem', marginBottom: '1.25rem', textAlign: 'center',
|
color: 'var(--blue)', fontSize: '0.85rem', marginBottom: '1.25rem', textAlign: 'center',
|
||||||
}}>
|
}}>
|
||||||
{program.crawl_status === 'failed'
|
{program.crawl_status === 'failed'
|
||||||
? `Crawl failed: ${program.crawl_error || 'unknown error'}`
|
? `Crawl failed: ${program.crawl_error || 'unknown error'}`
|
||||||
|
|
@ -210,7 +210,7 @@ export default function CatalogDetail() {
|
||||||
marginBottom: '1.25rem', boxShadow: 'var(--shadow-1)',
|
marginBottom: '1.25rem', boxShadow: 'var(--shadow-1)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent)',
|
fontSize: '0.72rem', fontWeight: 700, color: 'var(--orange)',
|
||||||
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px',
|
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px',
|
||||||
}}>📈 Progression</div>
|
}}>📈 Progression</div>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-2)', lineHeight: 1.5 }}>
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-2)', lineHeight: 1.5 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import { api } from '../api'
|
|
||||||
|
|
||||||
export default function ChatCoach() {
|
|
||||||
const [messages, setMessages] = useState([])
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const [streaming, setStreaming] = useState(false)
|
|
||||||
const [aiStatus, setAiStatus] = useState(null)
|
|
||||||
const scrollRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.getAIStatus().then(setAiStatus).catch(() => setAiStatus({ configured: false }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}, [messages])
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
|
||||||
const text = input.trim()
|
|
||||||
if (!text || streaming) return
|
|
||||||
|
|
||||||
const userMsg = { role: 'user', content: text }
|
|
||||||
setMessages(prev => [...prev, userMsg])
|
|
||||||
setInput('')
|
|
||||||
setStreaming(true)
|
|
||||||
|
|
||||||
// Add placeholder for assistant
|
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const history = messages.map(m => ({ role: m.role, content: m.content }))
|
|
||||||
const token = localStorage.getItem('fitness_token')
|
|
||||||
const resp = await fetch('/api/ai/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ message: text, history }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const reader = resp.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ') && line.trim() !== 'data: [DONE]') {
|
|
||||||
try {
|
|
||||||
const { text: chunk } = JSON.parse(line.slice(6))
|
|
||||||
if (chunk) {
|
|
||||||
setMessages(prev => {
|
|
||||||
const updated = [...prev]
|
|
||||||
const last = updated[updated.length - 1]
|
|
||||||
updated[updated.length - 1] = { ...last, content: last.content + chunk }
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setMessages(prev => {
|
|
||||||
const updated = [...prev]
|
|
||||||
updated[updated.length - 1] = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Connection error. Check that the backend is running and an AI provider is configured in Settings → Integrations.',
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setStreaming(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestions = [
|
|
||||||
'What should I eat today?',
|
|
||||||
'Log my 30-minute run',
|
|
||||||
'How am I doing this week?',
|
|
||||||
'Create a meal plan for me',
|
|
||||||
]
|
|
||||||
|
|
||||||
if (aiStatus && !aiStatus.configured) {
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<h1 className="page-title">AI Coach</h1>
|
|
||||||
<div className="card" style={{ textAlign: 'center', padding: '32px 20px' }}>
|
|
||||||
<div style={{ fontSize: '2rem', marginBottom: '12px' }}>🤖</div>
|
|
||||||
<h3 style={{ marginBottom: '8px' }}>No AI provider connected</h3>
|
|
||||||
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem', marginBottom: '16px' }}>
|
|
||||||
Connect OpenAI, OpenRouter, Claude, Ollama, or any other provider to start chatting with your AI fitness coach.
|
|
||||||
</p>
|
|
||||||
<a href="/settings/integrations" className="btn-primary" style={{
|
|
||||||
display: 'inline-block', padding: '12px 24px', borderRadius: 'var(--r)',
|
|
||||||
background: 'var(--accent)', color: 'var(--text-inv)', textDecoration: 'none',
|
|
||||||
fontWeight: 600, fontSize: '0.88rem',
|
|
||||||
}}>
|
|
||||||
Configure AI Provider
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page" style={{ display: 'flex', flexDirection: 'column', paddingBottom: '96px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
||||||
<h1 className="page-title" style={{ marginBottom: 0 }}>AI Coach</h1>
|
|
||||||
{aiStatus?.provider && (
|
|
||||||
<span style={{
|
|
||||||
fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--text-3)',
|
|
||||||
background: 'var(--accent-bg)', padding: '4px 10px', borderRadius: 'var(--r-full)',
|
|
||||||
}}>
|
|
||||||
{aiStatus.provider} · {aiStatus.model}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', marginBottom: '12px' }}>
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
|
||||||
<p style={{ color: 'var(--text-3)', fontSize: '0.9rem', marginBottom: '16px' }}>
|
|
||||||
Ask me anything about your fitness, nutrition, or program.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'center' }}>
|
|
||||||
{suggestions.map(s => (
|
|
||||||
<button key={s} className="btn-outline btn-sm" onClick={() => { setInput(s); }}
|
|
||||||
style={{ fontSize: '0.8rem' }}>
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.map((msg, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
|
||||||
marginBottom: '10px',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
maxWidth: '85%',
|
|
||||||
padding: '10px 14px',
|
|
||||||
borderRadius: msg.role === 'user'
|
|
||||||
? 'var(--r-lg) var(--r-lg) var(--r-sm) var(--r-lg)'
|
|
||||||
: 'var(--r-lg) var(--r-lg) var(--r-lg) var(--r-sm)',
|
|
||||||
background: msg.role === 'user' ? 'var(--accent)' : 'var(--card)',
|
|
||||||
color: msg.role === 'user' ? 'var(--text-inv)' : 'var(--text)',
|
|
||||||
border: msg.role === 'user' ? 'none' : '1px solid var(--card-border)',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
}}>
|
|
||||||
{msg.content || (streaming && i === messages.length - 1 ? '...' : '')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div ref={scrollRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', gap: '8px',
|
|
||||||
position: 'sticky', bottom: '72px',
|
|
||||||
background: 'var(--bg)', padding: '8px 0',
|
|
||||||
}}>
|
|
||||||
<input
|
|
||||||
value={input}
|
|
||||||
onChange={e => setInput(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
|
||||||
placeholder={streaming ? 'Thinking...' : 'Ask your AI coach...'}
|
|
||||||
disabled={streaming}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={sendMessage}
|
|
||||||
disabled={streaming || !input.trim()}
|
|
||||||
className="btn-primary"
|
|
||||||
style={{ padding: '12px 18px', minWidth: 'auto' }}
|
|
||||||
>
|
|
||||||
{streaming ? '···' : '→'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -76,8 +76,8 @@ export default function Dashboard() {
|
||||||
const polarConnected = integrations?.polar?.connected
|
const polarConnected = integrations?.polar?.connected
|
||||||
|
|
||||||
const activities = [
|
const activities = [
|
||||||
{ key: 'workout', label: 'Workout', icon: '💪', pts: 50, color: 'var(--accent)', tip: 'Any exercise session — running, weights, yoga, etc. Logged manually or synced from Strava/Polar.' },
|
{ key: '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: 'var(--green)', tip: 'Log what you eat. Barcode scan or manual entry. Building food awareness is a key habit.' },
|
{ key: 'food_log', label: 'Food logged', icon: '🥗', pts: 30, color: '#4CAF50', tip: 'Log what you eat. Barcode scan or manual entry. Building food awareness is a key habit.' },
|
||||||
{ key: 'steps_target', label: 'Steps target', icon: '👟', pts: 20, color: '#2979FF', tip: 'Hit your daily step goal. Steps are the foundation of an active lifestyle.' },
|
{ key: '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: '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.' },
|
{ 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
|
<div
|
||||||
onClick={() => status.program_id && navigate(`/programs/${status.program_id}`)}
|
onClick={() => status.program_id && navigate(`/programs/${status.program_id}`)}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--accent-bg)', borderRadius: 'var(--r)', padding: '12px 16px',
|
background: 'var(--blue-ghost)', borderRadius: 'var(--r)', padding: '12px 16px',
|
||||||
marginBottom: '14px', display: 'flex', alignItems: 'center', gap: '12px',
|
marginBottom: '14px', display: 'flex', alignItems: 'center', gap: '12px',
|
||||||
border: '1px solid rgba(41,121,255,0.1)',
|
border: '1px solid rgba(41,121,255,0.1)',
|
||||||
cursor: status.program_id ? 'pointer' : 'default',
|
cursor: status.program_id ? 'pointer' : 'default',
|
||||||
|
|
@ -98,15 +98,15 @@ export default function Dashboard() {
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '1.1rem' }}>📋</div>
|
<div style={{ fontSize: '1.1rem' }}>📋</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--accent)' }}>{status.program_name}</div>
|
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--blue)' }}>{status.program_name}</div>
|
||||||
<div className="progress-bar" style={{ height: '5px', marginTop: '4px' }}>
|
<div className="progress-bar" style={{ height: '5px', marginTop: '4px' }}>
|
||||||
<div className="progress-bar-fill" style={{
|
<div className="progress-bar-fill" style={{
|
||||||
width: `${Math.round((status.program_day / status.program_total_days) * 100)}%`,
|
width: `${Math.round((status.program_day / status.program_total_days) * 100)}%`,
|
||||||
background: 'var(--accent)',
|
background: 'var(--blue)',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: '0.85rem', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
|
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: '0.85rem', color: 'var(--blue)', whiteSpace: 'nowrap' }}>
|
||||||
{status.program_day}/{status.program_total_days}
|
{status.program_day}/{status.program_total_days}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,7 +120,7 @@ export default function Dashboard() {
|
||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</div>
|
||||||
<div className="gate-pts">
|
<div className="gate-pts">
|
||||||
<span style={{ color: status.gate_passed ? 'var(--green)' : 'var(--accent)' }}>
|
<span style={{ color: status.gate_passed ? 'var(--green)' : 'var(--orange)' }}>
|
||||||
{status.points_earned}
|
{status.points_earned}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontFamily: 'var(--font)', fontSize: '1rem', fontWeight: 600, color: 'var(--text-3)' }}>
|
<span style={{ fontFamily: 'var(--font)', fontSize: '1rem', fontWeight: 600, color: 'var(--text-3)' }}>
|
||||||
|
|
@ -132,7 +132,7 @@ export default function Dashboard() {
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
background: status.gate_passed
|
background: status.gate_passed
|
||||||
? 'linear-gradient(90deg, #00C853, #69F0AE)'
|
? 'linear-gradient(90deg, #00C853, #69F0AE)'
|
||||||
: `linear-gradient(90deg, var(--accent), var(--accent-light))`,
|
: `linear-gradient(90deg, #FF5722, #FF8A65)`,
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '12px', fontSize: '0.88rem', fontWeight: 700 }}>
|
<div style={{ marginTop: '12px', fontSize: '0.88rem', fontWeight: 700 }}>
|
||||||
|
|
@ -178,7 +178,7 @@ export default function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* === Week Progress === */}
|
{/* === Week Progress === */}
|
||||||
<div className="card" style={{ background: 'var(--accent-bg)' }}>
|
<div className="card" style={{ background: 'var(--purple-ghost)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||||
<div className="section-label" style={{ margin: 0 }}>
|
<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.`}>
|
<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 === */}
|
{/* === Rewards === */}
|
||||||
{status.gate_passed && status.rewards_available.length > 0 && (
|
{status.gate_passed && status.rewards_available.length > 0 && (
|
||||||
<div className="card" style={{ background: 'var(--green-bg)', border: '2px solid rgba(0,200,83,0.15)' }}>
|
<div className="card" style={{ background: 'var(--green-ghost)', border: '2px solid rgba(0,200,83,0.15)' }}>
|
||||||
<div className="section-label" style={{ color: 'var(--green-dark)' }}>🎮 Rewards Available</div>
|
<div className="section-label" style={{ color: 'var(--green-dark)' }}>🎮 Rewards Available</div>
|
||||||
{status.rewards_available.map(r => (
|
{status.rewards_available.map(r => (
|
||||||
<div className="reward-card" key={r.id}>
|
<div className="reward-card" key={r.id}>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ value: 'workout', label: 'Workout', icon: '💪', desc: 'Any exercise session', color: 'var(--accent)' },
|
{ value: 'workout', label: 'Workout', icon: '💪', desc: 'Any exercise session', color: '#FF5722' },
|
||||||
{ value: 'steps_target', label: 'Steps', icon: '👟', desc: 'Hit your daily goal', color: '#2979FF' },
|
{ value: '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: 'screen_free', label: 'Screen-Free', icon: '📖', desc: 'Reading, outdoors', color: '#7C4DFF' },
|
||||||
{ value: 'daily_checkin', label: 'Check-in', icon: '✅', desc: 'Just show up', color: '#00BCD4' },
|
{ value: 'daily_checkin', label: 'Check-in', icon: '✅', desc: 'Just show up', color: '#00BCD4' },
|
||||||
|
|
@ -100,7 +100,7 @@ export default function LogActivity() {
|
||||||
{success && (
|
{success && (
|
||||||
<div style={{
|
<div style={{
|
||||||
textAlign: 'center', padding: '24px', marginBottom: '14px',
|
textAlign: 'center', padding: '24px', marginBottom: '14px',
|
||||||
background: 'var(--green-bg)', borderRadius: 'var(--r-lg)', border: '2px solid rgba(0,200,83,0.15)',
|
background: 'var(--green-ghost)', borderRadius: 'var(--r-lg)', border: '2px solid rgba(0,200,83,0.15)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '2rem', marginBottom: '4px' }}>🎉</div>
|
<div style={{ fontSize: '2rem', marginBottom: '4px' }}>🎉</div>
|
||||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.3rem', color: 'var(--green-dark)' }}>{success}</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
|
<div
|
||||||
onClick={() => navigate(`/programs/${activeProgram.id}`)}
|
onClick={() => navigate(`/programs/${activeProgram.id}`)}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--accent-bg)', borderRadius: 'var(--r-sm)', padding: '10px 14px',
|
background: 'var(--blue-ghost)', borderRadius: 'var(--r-sm)', padding: '10px 14px',
|
||||||
marginBottom: '12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
marginBottom: '12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
cursor: 'pointer', border: '1px solid rgba(41,121,255,0.15)',
|
cursor: 'pointer', border: '1px solid rgba(41,121,255,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--blue)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
Active Program
|
Active Program
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontWeight: 800, fontSize: '0.92rem', color: 'var(--text-1)' }}>
|
<div style={{ fontWeight: 800, fontSize: '0.92rem', color: 'var(--text-1)' }}>
|
||||||
{activeProgram.name}
|
{activeProgram.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--accent)', fontWeight: 700 }}>
|
<div style={{ fontSize: '0.78rem', color: 'var(--blue)', fontWeight: 700 }}>
|
||||||
Week {activeProgram.current_week || 1} →
|
Week {activeProgram.current_week || 1} →
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,7 +176,7 @@ export default function LogActivity() {
|
||||||
|
|
||||||
{!todayWorkout && upcomingWorkouts.length === 0 && (
|
{!todayWorkout && upcomingWorkouts.length === 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '14px', borderRadius: 'var(--r-sm)', background: 'var(--green-bg)',
|
padding: '14px', borderRadius: 'var(--r-sm)', background: 'var(--green-ghost)',
|
||||||
color: 'var(--green-dark)', fontSize: '0.85rem', textAlign: 'center', fontWeight: 600,
|
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.
|
✓ 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={{
|
<div style={{
|
||||||
background: 'var(--card)', borderRadius: 'var(--r)', padding: '14px',
|
background: 'var(--card)', borderRadius: 'var(--r)', padding: '14px',
|
||||||
marginBottom: '10px', boxShadow: 'var(--shadow-1)',
|
marginBottom: '10px', boxShadow: 'var(--shadow-1)',
|
||||||
border: featured ? '2px solid var(--accent-glow)' : '1px solid var(--divider)',
|
border: featured ? '2px solid var(--orange-glow)' : '1px solid var(--divider)',
|
||||||
}}>
|
}}>
|
||||||
{featured && (
|
{featured && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '0.65rem', fontWeight: 800, color: 'var(--accent)',
|
fontSize: '0.65rem', fontWeight: 800, color: 'var(--orange)',
|
||||||
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '4px',
|
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '4px',
|
||||||
}}>
|
}}>
|
||||||
⭐ Today
|
⭐ Today
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ export default function LogFood() {
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1 className="page-title">Food Log</h1>
|
<h1 className="page-title">Food Log</h1>
|
||||||
|
|
||||||
{success && <div style={{ padding: '12px', background: 'var(--green-bg)', borderRadius: 'var(--radius-sm)', color: 'var(--green-dark)', textAlign: 'center', marginBottom: '12px', fontWeight: 600 }}>{success}</div>}
|
{success && <div style={{ padding: '12px', background: 'var(--green-ghost)', borderRadius: 'var(--radius-sm)', color: 'var(--green-dark)', textAlign: 'center', marginBottom: '12px', fontWeight: 600 }}>{success}</div>}
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export default function Login() {
|
||||||
minHeight: '100dvh',
|
minHeight: '100dvh',
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
background: 'linear-gradient(170deg, var(--accent-light) 0%, var(--accent) 30%, var(--accent-dark) 60%, var(--text) 100%)',
|
background: 'linear-gradient(170deg, #FF8A65 0%, #FF5722 30%, #E64A19 60%, #1B1B2F 100%)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: '100%', maxWidth: '380px' }}>
|
<div style={{ width: '100%', maxWidth: '380px' }}>
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
|
|
@ -61,9 +61,9 @@ export default function Login() {
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 'var(--r-sm)', padding: '10px',
|
borderRadius: 'var(--r-sm)', padding: '10px',
|
||||||
fontWeight: 700, fontSize: '0.85rem',
|
fontWeight: 700, fontSize: '0.85rem',
|
||||||
background: mode === m ? 'var(--accent)' : 'transparent',
|
background: mode === m ? 'var(--orange)' : 'transparent',
|
||||||
color: mode === m ? '#fff' : 'var(--text-2)',
|
color: mode === m ? '#fff' : 'var(--text-2)',
|
||||||
boxShadow: mode === m ? 'var(--shadow-accent)' : 'none',
|
boxShadow: mode === m ? 'var(--shadow-orange)' : 'none',
|
||||||
}}>
|
}}>
|
||||||
{m === 'login' ? 'Sign In' : 'Sign Up'}
|
{m === 'login' ? 'Sign In' : 'Sign Up'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,9 @@ export default function MealPlan() {
|
||||||
<div key={meal.id} style={{
|
<div key={meal.id} style={{
|
||||||
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 16,
|
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 16,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
borderLeft: itemStatus === 'followed' ? '4px solid var(--green)' :
|
borderLeft: itemStatus === 'followed' ? '4px solid #4CAF50' :
|
||||||
itemStatus === 'skipped' ? '4px solid var(--red)' :
|
itemStatus === 'skipped' ? '4px solid #F44336' :
|
||||||
itemStatus === 'substituted' ? '4px solid var(--amber)' : '4px solid transparent',
|
itemStatus === 'substituted' ? '4px solid #FF9800' : '4px solid transparent',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -141,8 +141,8 @@ export default function MealPlan() {
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.75rem', padding: '2px 8px', borderRadius: 12,
|
fontSize: '0.75rem', padding: '2px 8px', borderRadius: 12,
|
||||||
background: p.status === 'active' ? 'var(--green)22' : 'var(--text-3)22',
|
background: p.status === 'active' ? '#4CAF5022' : '#9E9E9E22',
|
||||||
color: p.status === 'active' ? 'var(--green)' : 'var(--text-3)',
|
color: p.status === 'active' ? '#4CAF50' : '#9E9E9E',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}>
|
}}>
|
||||||
{p.status}
|
{p.status}
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ export default function Nutrition() {
|
||||||
<div className={`gate-banner ${c.compliant_day ? 'gate-earned' : 'gate-locked'}`}>
|
<div className={`gate-banner ${c.compliant_day ? 'gate-earned' : 'gate-locked'}`}>
|
||||||
<div className="section-label" style={{ marginBottom: '4px' }}>Today's Keto Day</div>
|
<div className="section-label" style={{ marginBottom: '4px' }}>Today's Keto Day</div>
|
||||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.4rem',
|
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.4rem',
|
||||||
color: c.compliant_day ? 'var(--green)' : 'var(--accent)' }}>
|
color: c.compliant_day ? 'var(--green)' : 'var(--orange)' }}>
|
||||||
{c.compliant_day ? '✓ Compliant' : '⏳ In progress'}
|
{c.compliant_day ? '✓ Compliant' : '⏳ In progress'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-2)', marginTop: '6px' }}>
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-2)', marginTop: '6px' }}>
|
||||||
|
|
@ -196,7 +196,7 @@ export default function Nutrition() {
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="section-label">Today's Macros</div>
|
<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="Net carbs" value={m.net_carbs_g} target={t.net_carbs_cap} unit="g" inverse />
|
||||||
<MacroBar label="Protein" value={m.protein_g} target={t.protein_g} unit="g" color="var(--accent)" />
|
<MacroBar label="Protein" value={m.protein_g} target={t.protein_g} unit="g" color="#FF5722" />
|
||||||
<MacroBar label="Fat" value={m.fat_g} target={t.fat_g} unit="g" color="#FFA726" />
|
<MacroBar label="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" />
|
<MacroBar label="Calories" value={m.calories} target={t.calories} unit="kcal" color="#2979FF" />
|
||||||
<button className="btn-outline btn-full btn-sm" onClick={() => navigate('/food')}
|
<button className="btn-outline btn-full btn-sm" onClick={() => navigate('/food')}
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ export default function Onboarding() {
|
||||||
return (
|
return (
|
||||||
<div className="page" style={{ paddingTop: '40px' }}>
|
<div className="page" style={{ paddingTop: '40px' }}>
|
||||||
<div className="progress-bar" style={{ marginBottom: '24px' }}>
|
<div className="progress-bar" style={{ marginBottom: '24px' }}>
|
||||||
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--accent)' }} />
|
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--orange)' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step 0: Goal */}
|
{/* Step 0: Goal */}
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,13 @@ export default function ProgramDetail() {
|
||||||
<div style={{ display: 'flex', gap: '1.5rem', marginTop: '10px', fontSize: '0.82rem', color: 'var(--text-2)' }}>
|
<div style={{ display: 'flex', gap: '1.5rem', marginTop: '10px', fontSize: '0.82rem', color: 'var(--text-2)' }}>
|
||||||
<span>Week {schedule.current_week}</span>
|
<span>Week {schedule.current_week}</span>
|
||||||
<span>{progress?.completed_workouts || 0}/{progress?.total_workouts || 0} workouts</span>
|
<span>{progress?.completed_workouts || 0}/{progress?.total_workouts || 0} workouts</span>
|
||||||
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>{pct}%</span>
|
<span style={{ color: 'var(--orange)', fontWeight: 700 }}>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div style={{ marginTop: '12px', height: '8px', borderRadius: '4px', background: 'var(--divider)' }}>
|
<div style={{ marginTop: '12px', height: '8px', borderRadius: '4px', background: 'var(--divider)' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%', borderRadius: '4px',
|
height: '100%', borderRadius: '4px',
|
||||||
background: pct >= 100 ? 'var(--green)' : 'var(--accent)',
|
background: pct >= 100 ? 'var(--green)' : 'var(--orange)',
|
||||||
width: `${Math.min(100, pct)}%`, transition: 'width 0.4s ease',
|
width: `${Math.min(100, pct)}%`, transition: 'width 0.4s ease',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,7 +86,7 @@ export default function ProgramDetail() {
|
||||||
{/* Completion celebration */}
|
{/* Completion celebration */}
|
||||||
{completionResult && (
|
{completionResult && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--green-bg)', borderRadius: 'var(--r)', padding: '16px',
|
background: 'var(--green-ghost)', borderRadius: 'var(--r)', padding: '16px',
|
||||||
marginBottom: '1rem', textAlign: 'center',
|
marginBottom: '1rem', textAlign: 'center',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '1.8rem', marginBottom: '4px' }}>🎉</div>
|
<div style={{ fontSize: '1.8rem', marginBottom: '4px' }}>🎉</div>
|
||||||
|
|
@ -110,10 +110,10 @@ export default function ProgramDetail() {
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
|
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
|
||||||
marginBottom: '1.5rem', boxShadow: 'var(--shadow-2)',
|
marginBottom: '1.5rem', boxShadow: 'var(--shadow-2)',
|
||||||
border: '2px solid var(--accent-glow)',
|
border: '2px solid var(--orange-glow)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent)',
|
fontSize: '0.72rem', fontWeight: 700, color: 'var(--orange)',
|
||||||
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '6px',
|
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '6px',
|
||||||
}}>Today's Workout</div>
|
}}>Today's Workout</div>
|
||||||
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1.1rem' }}>
|
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1.1rem' }}>
|
||||||
|
|
@ -155,7 +155,7 @@ export default function ProgramDetail() {
|
||||||
Week {week}
|
Week {week}
|
||||||
{week === schedule.current_week && (
|
{week === schedule.current_week && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.65rem', background: 'var(--accent)', color: '#fff',
|
fontSize: '0.65rem', background: 'var(--orange)', color: '#fff',
|
||||||
padding: '2px 8px', borderRadius: 'var(--r-full)',
|
padding: '2px 8px', borderRadius: 'var(--r-full)',
|
||||||
}}>Current</span>
|
}}>Current</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -185,7 +185,7 @@ export default function ProgramDetail() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowComplete(w)}
|
onClick={() => setShowComplete(w)}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--green-bg)', color: 'var(--green-dark)',
|
background: 'var(--green-ghost)', color: 'var(--green-dark)',
|
||||||
padding: '6px 12px', fontSize: '0.75rem', fontWeight: 700,
|
padding: '6px 12px', fontSize: '0.75rem', fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -223,7 +223,7 @@ export default function ProgramDetail() {
|
||||||
{showComplete.notes && (
|
{showComplete.notes && (
|
||||||
<p style={{
|
<p style={{
|
||||||
marginTop: '12px', fontSize: '0.8rem', color: 'var(--text-2)',
|
marginTop: '12px', fontSize: '0.8rem', color: 'var(--text-2)',
|
||||||
background: 'var(--accent-bg)', padding: '10px 12px', borderRadius: 'var(--r-sm)',
|
background: 'var(--blue-ghost)', padding: '10px 12px', borderRadius: 'var(--r-sm)',
|
||||||
}}>
|
}}>
|
||||||
💡 {showComplete.notes}
|
💡 {showComplete.notes}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -269,7 +269,7 @@ function ExerciseRow({ exercise, detailed }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detailed && exercise.weight_instruction && (
|
{detailed && exercise.weight_instruction && (
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--accent)', marginTop: '2px' }}>
|
<div style={{ fontSize: '0.75rem', color: 'var(--blue)', marginTop: '2px' }}>
|
||||||
🏋️ {exercise.weight_instruction}
|
🏋️ {exercise.weight_instruction}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { api } from '../api'
|
||||||
|
|
||||||
const DIFFICULTY_COLORS = {
|
const DIFFICULTY_COLORS = {
|
||||||
beginner: 'var(--green)',
|
beginner: 'var(--green)',
|
||||||
intermediate: 'var(--accent)',
|
intermediate: 'var(--blue)',
|
||||||
advanced: 'var(--accent-light)',
|
advanced: 'var(--purple)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_ICONS = {
|
const CATEGORY_ICONS = {
|
||||||
|
|
@ -17,8 +17,8 @@ const CATEGORY_ICONS = {
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
pending: { label: 'Queued', color: 'var(--amber)' },
|
pending: { label: 'Queued', color: 'var(--amber)' },
|
||||||
crawling: { label: 'Fetching...', color: 'var(--accent)' },
|
crawling: { label: 'Fetching...', color: 'var(--blue)' },
|
||||||
extracting: { label: 'Analyzing...', color: 'var(--accent-light)' },
|
extracting: { label: 'Analyzing...', color: 'var(--purple)' },
|
||||||
ready: { label: 'Ready', color: 'var(--green)' },
|
ready: { label: 'Ready', color: 'var(--green)' },
|
||||||
failed: { label: 'Failed', color: 'var(--red)' },
|
failed: { label: 'Failed', color: 'var(--red)' },
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +117,7 @@ export default function ProgramSearch() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button type="submit" style={{
|
<button type="submit" style={{
|
||||||
background: 'var(--accent)', color: '#fff', padding: '12px 18px',
|
background: 'var(--blue)', color: '#fff', padding: '12px 18px',
|
||||||
boxShadow: 'var(--shadow-1)',
|
boxShadow: 'var(--shadow-1)',
|
||||||
}}>Search</button>
|
}}>Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -126,9 +126,9 @@ export default function ProgramSearch() {
|
||||||
onClick={handleResearch}
|
onClick={handleResearch}
|
||||||
disabled={researching || !query.trim()}
|
disabled={researching || !query.trim()}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', background: researching ? 'var(--text-3)' : 'var(--accent)',
|
width: '100%', background: researching ? 'var(--text-3)' : 'var(--orange)',
|
||||||
color: '#fff', padding: '14px', marginBottom: '1rem',
|
color: '#fff', padding: '14px', marginBottom: '1rem',
|
||||||
boxShadow: researching ? 'none' : 'var(--shadow-accent)',
|
boxShadow: researching ? 'none' : 'var(--shadow-orange)',
|
||||||
opacity: !query.trim() ? 0.5 : 1,
|
opacity: !query.trim() ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -140,8 +140,8 @@ export default function ProgramSearch() {
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px 16px', borderRadius: 'var(--r-sm)', marginBottom: '1rem',
|
padding: '12px 16px', borderRadius: 'var(--r-sm)', marginBottom: '1rem',
|
||||||
fontSize: '0.85rem', fontWeight: 600,
|
fontSize: '0.85rem', fontWeight: 600,
|
||||||
background: message.type === 'error' ? 'var(--red-ghost)' : message.type === 'success' ? 'var(--green-bg)' : 'var(--accent-bg)',
|
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(--accent)',
|
color: message.type === 'error' ? 'var(--red)' : message.type === 'success' ? 'var(--green-dark)' : 'var(--blue)',
|
||||||
}}>
|
}}>
|
||||||
{message.text}
|
{message.text}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,12 +160,12 @@ export default function ProgramSearch() {
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
|
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
|
||||||
marginBottom: '0.75rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer',
|
marginBottom: '0.75rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer',
|
||||||
border: '2px solid var(--accent-glow)',
|
border: '2px solid var(--orange-glow)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1rem' }}>{p.name}</strong>
|
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1rem' }}>{p.name}</strong>
|
||||||
<span style={{ fontSize: '0.8rem', color: 'var(--accent)', fontWeight: 700 }}>
|
<span style={{ fontSize: '0.8rem', color: 'var(--orange)', fontWeight: 700 }}>
|
||||||
Day {p.current_day}/{p.total_days}
|
Day {p.current_day}/{p.total_days}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,7 +173,7 @@ export default function ProgramSearch() {
|
||||||
marginTop: '8px', height: '6px', borderRadius: '3px', background: 'var(--divider)',
|
marginTop: '8px', height: '6px', borderRadius: '3px', background: 'var(--divider)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%', borderRadius: '3px', background: 'var(--accent)',
|
height: '100%', borderRadius: '3px', background: 'var(--orange)',
|
||||||
width: `${Math.min(100, (p.current_day / p.total_days) * 100)}%`,
|
width: `${Math.min(100, (p.current_day / p.total_days) * 100)}%`,
|
||||||
transition: 'width 0.3s ease',
|
transition: 'width 0.3s ease',
|
||||||
}} />
|
}} />
|
||||||
|
|
@ -272,7 +272,7 @@ export default function ProgramSearch() {
|
||||||
onClick={() => navigate(`/catalog/${p.id}`)}
|
onClick={() => navigate(`/catalog/${p.id}`)}
|
||||||
style={{
|
style={{
|
||||||
marginTop: adopted ? '4px' : '8px', width: '100%', background: 'transparent',
|
marginTop: adopted ? '4px' : '8px', width: '100%', background: 'transparent',
|
||||||
color: 'var(--accent)', padding: '8px', fontSize: '0.82rem',
|
color: 'var(--blue)', padding: '8px', fontSize: '0.82rem',
|
||||||
border: '1px solid var(--divider)',
|
border: '1px solid var(--divider)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export default function Settings() {
|
||||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '48px', height: '48px', borderRadius: '14px',
|
width: '48px', height: '48px', borderRadius: '14px',
|
||||||
background: 'linear-gradient(135deg, var(--accent), var(--accent-light))',
|
background: 'linear-gradient(135deg, #FF5722, #FF8A65)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
color: '#fff', fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.2rem',
|
color: '#fff', fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.2rem',
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -60,75 +60,6 @@ export default function Settings() {
|
||||||
</div>
|
</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 */}
|
{/* Point Rules */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="section-label">Point Rules</div>
|
<div className="section-label">Point Rules</div>
|
||||||
|
|
@ -188,12 +119,6 @@ export default function Settings() {
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button className="btn-danger btn-full" style={{ marginTop: '10px' }} onClick={() => { clearToken(); navigate('/login') }}>
|
<button className="btn-danger btn-full" style={{ marginTop: '10px' }} onClick={() => { clearToken(); navigate('/login') }}>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
connected: 'var(--green)',
|
connected: '#4CAF50',
|
||||||
configured: 'var(--amber)',
|
configured: '#FF9800',
|
||||||
not_configured: 'var(--text-3)',
|
not_configured: '#9E9E9E',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ export default function Support() {
|
||||||
borderRadius: m.sender === 'user'
|
borderRadius: m.sender === 'user'
|
||||||
? 'var(--r) var(--r) 4px var(--r)'
|
? 'var(--r) var(--r) 4px var(--r)'
|
||||||
: 'var(--r) var(--r) var(--r) 4px',
|
: 'var(--r) var(--r) var(--r) 4px',
|
||||||
background: m.sender === 'user' ? 'var(--accent)' : 'var(--card)',
|
background: m.sender === 'user' ? 'var(--orange)' : 'var(--card)',
|
||||||
color: m.sender === 'user' ? '#fff' : 'var(--text)',
|
color: m.sender === 'user' ? '#fff' : 'var(--text)',
|
||||||
boxShadow: 'var(--shadow-1)',
|
boxShadow: 'var(--shadow-1)',
|
||||||
fontSize: '0.88rem',
|
fontSize: '0.88rem',
|
||||||
|
|
@ -116,7 +116,7 @@ export default function Support() {
|
||||||
{confirmation && (
|
{confirmation && (
|
||||||
<div style={{
|
<div style={{
|
||||||
textAlign: 'center', fontSize: '0.8rem', color: 'var(--green-dark)',
|
textAlign: 'center', fontSize: '0.8rem', color: 'var(--green-dark)',
|
||||||
padding: '8px', background: 'var(--green-bg)', borderRadius: 'var(--r-sm)',
|
padding: '8px', background: 'var(--green-ghost)', borderRadius: 'var(--r-sm)',
|
||||||
}}>
|
}}>
|
||||||
{confirmation}
|
{confirmation}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -146,11 +146,11 @@ export default function Support() {
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!input.trim() || sending}
|
disabled={!input.trim() || sending}
|
||||||
style={{
|
style={{
|
||||||
background: sending ? 'var(--text-3)' : 'var(--accent)',
|
background: sending ? 'var(--text-3)' : 'var(--orange)',
|
||||||
color: '#fff', borderRadius: 'var(--r-full)',
|
color: '#fff', borderRadius: 'var(--r-full)',
|
||||||
width: '44px', height: '44px', padding: 0,
|
width: '44px', height: '44px', padding: 0,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: '1.1rem', boxShadow: 'var(--shadow-accent)',
|
fontSize: '1.1rem', boxShadow: 'var(--shadow-orange)',
|
||||||
opacity: !input.trim() ? 0.5 : 1,
|
opacity: !input.trim() ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ function AdminThreadList() {
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--card)', borderRadius: 'var(--r)', padding: '14px 16px',
|
background: 'var(--card)', borderRadius: 'var(--r)', padding: '14px 16px',
|
||||||
marginBottom: '0.6rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer',
|
marginBottom: '0.6rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer',
|
||||||
border: t.unread_admin > 0 ? '2px solid var(--accent-glow)' : '1px solid var(--card-border)',
|
border: t.unread_admin > 0 ? '2px solid var(--orange-glow)' : '1px solid var(--card-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
|
@ -66,7 +66,7 @@ function AdminThreadList() {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{t.unread_admin > 0 && (
|
{t.unread_admin > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
background: 'var(--accent)', color: '#fff', fontSize: '0.65rem', fontWeight: 800,
|
background: 'var(--orange)', color: '#fff', fontSize: '0.65rem', fontWeight: 800,
|
||||||
width: '20px', height: '20px', borderRadius: '50%',
|
width: '20px', height: '20px', borderRadius: '50%',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>{t.unread_admin}</span>
|
}}>{t.unread_admin}</span>
|
||||||
|
|
@ -168,7 +168,7 @@ function AdminThread({ threadId }) {
|
||||||
)}
|
)}
|
||||||
<span style={{
|
<span style={{
|
||||||
...ctxTag,
|
...ctxTag,
|
||||||
background: ctx.gate_passed ? 'var(--green-bg)' : 'var(--red-ghost)',
|
background: ctx.gate_passed ? 'var(--green-ghost)' : 'var(--red-ghost)',
|
||||||
color: ctx.gate_passed ? 'var(--green-dark)' : 'var(--red)',
|
color: ctx.gate_passed ? 'var(--green-dark)' : 'var(--red)',
|
||||||
}}>
|
}}>
|
||||||
{ctx.points_today}/{ctx.daily_target} pts {ctx.gate_passed ? '✓' : '✗'}
|
{ctx.points_today}/{ctx.daily_target} pts {ctx.gate_passed ? '✓' : '✗'}
|
||||||
|
|
@ -193,7 +193,7 @@ function AdminThread({ threadId }) {
|
||||||
borderRadius: m.sender === 'admin'
|
borderRadius: m.sender === 'admin'
|
||||||
? 'var(--r) var(--r) 4px var(--r)'
|
? 'var(--r) var(--r) 4px var(--r)'
|
||||||
: 'var(--r) var(--r) var(--r) 4px',
|
: 'var(--r) var(--r) var(--r) 4px',
|
||||||
background: m.sender === 'admin' ? 'var(--accent)' : 'var(--card)',
|
background: m.sender === 'admin' ? 'var(--blue)' : 'var(--card)',
|
||||||
color: m.sender === 'admin' ? '#fff' : 'var(--text)',
|
color: m.sender === 'admin' ? '#fff' : 'var(--text)',
|
||||||
boxShadow: 'var(--shadow-1)',
|
boxShadow: 'var(--shadow-1)',
|
||||||
fontSize: '0.88rem',
|
fontSize: '0.88rem',
|
||||||
|
|
@ -233,7 +233,7 @@ function AdminThread({ threadId }) {
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!input.trim() || sending}
|
disabled={!input.trim() || sending}
|
||||||
style={{
|
style={{
|
||||||
background: sending ? 'var(--text-3)' : 'var(--accent)',
|
background: sending ? 'var(--text-3)' : 'var(--blue)',
|
||||||
color: '#fff', borderRadius: 'var(--r-full)',
|
color: '#fff', borderRadius: 'var(--r-full)',
|
||||||
width: '44px', height: '44px', padding: 0,
|
width: '44px', height: '44px', padding: 0,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default function WeekView() {
|
||||||
<h1 className="page-title">This Week</h1>
|
<h1 className="page-title">This Week</h1>
|
||||||
|
|
||||||
{/* Summary hero */}
|
{/* Summary hero */}
|
||||||
<div className="card" style={{ textAlign: 'center', background: 'var(--accent-bg)' }}>
|
<div className="card" style={{ textAlign: 'center', background: 'var(--purple-ghost)' }}>
|
||||||
<div className="section-label">Weekly Total</div>
|
<div className="section-label">Weekly Total</div>
|
||||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: '2.6rem', fontWeight: 900, letterSpacing: '-0.03em' }}>
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: '2.6rem', fontWeight: 900, letterSpacing: '-0.03em' }}>
|
||||||
{week.total_points_earned}
|
{week.total_points_earned}
|
||||||
|
|
@ -68,7 +68,7 @@ export default function WeekView() {
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '38px', fontWeight: isToday ? 800 : 600, fontSize: '0.85rem',
|
width: '38px', fontWeight: isToday ? 800 : 600, fontSize: '0.85rem',
|
||||||
color: isToday ? 'var(--accent)' : 'var(--text)',
|
color: isToday ? 'var(--orange)' : 'var(--text)',
|
||||||
}}>
|
}}>
|
||||||
{days[i]}
|
{days[i]}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
.git
|
|
||||||
.env
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
|
|
@ -12,7 +12,6 @@ from datetime import date
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
FITNESS_API_URL = os.getenv("FITNESS_API_URL", "http://backend:8000")
|
FITNESS_API_URL = os.getenv("FITNESS_API_URL", "http://backend:8000")
|
||||||
API_TOKEN = os.getenv("API_TOKEN", "")
|
|
||||||
|
|
||||||
mcp = FastMCP("Diligence Fitness", port=3001)
|
mcp = FastMCP("Diligence Fitness", port=3001)
|
||||||
|
|
||||||
|
|
@ -22,12 +21,8 @@ _client: httpx.AsyncClient | None = None
|
||||||
async def api(method: str, path: str, **kwargs) -> dict:
|
async def api(method: str, path: str, **kwargs) -> dict:
|
||||||
global _client
|
global _client
|
||||||
if _client is None:
|
if _client is None:
|
||||||
headers = {}
|
_client = httpx.AsyncClient(base_url=FITNESS_API_URL, timeout=30)
|
||||||
if API_TOKEN:
|
# TODO: add service account API key auth header
|
||||||
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 = await getattr(_client, method)(f"/api{path}", **kwargs)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
|
||||||
29
setup.ps1
29
setup.ps1
|
|
@ -1,29 +0,0 @@
|
||||||
# Diligence — Windows Setup
|
|
||||||
# Generate API_TOKEN for MCP connector auth
|
|
||||||
$apiToken = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object {[char]$_})
|
|
||||||
(Get-Content .env) -replace '^API_TOKEN=.*', "API_TOKEN=$apiToken" | Set-Content .env
|
|
||||||
|
|
||||||
Write-Host "`n`e[36m💪 Diligence — Setup`e[0m`n"
|
|
||||||
|
|
||||||
if (Test-Path .env) {
|
|
||||||
Write-Host "`e[33m.env already exists. Delete it first to regenerate.`e[0m"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate random SECRET_KEY (64 hex chars)
|
|
||||||
$bytes = New-Object byte[] 32
|
|
||||||
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
|
||||||
$secret = ($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
|
||||||
|
|
||||||
# Create .env from template
|
|
||||||
Copy-Item .env.example .env
|
|
||||||
(Get-Content .env) -replace "^SECRET_KEY=.*", "SECRET_KEY=$secret" | Set-Content .env
|
|
||||||
|
|
||||||
Write-Host "`e[32m✅ .env created with random SECRET_KEY`e[0m"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Next steps:"
|
|
||||||
Write-Host " docker compose up -d"
|
|
||||||
Write-Host " Open http://localhost"
|
|
||||||
Write-Host " Register your account"
|
|
||||||
Write-Host " Configure integrations via Settings or your AI agent"
|
|
||||||
Write-Host ""
|
|
||||||
11
setup.sh
11
setup.sh
|
|
@ -7,20 +7,13 @@ if [ -f .env ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SECRET=$(openssl rand -hex 32)
|
SECRET=$(openssl rand -hex 32)
|
||||||
TOKEN=$(openssl rand -hex 32)
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
|
sed -i "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 and API_TOKEN"
|
echo "✅ .env created with random SECRET_KEY"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " docker compose up -d"
|
echo " docker compose up -d"
|
||||||
echo " Open http://localhost"
|
echo " Open http://localhost"
|
||||||
echo " Register your account"
|
echo " Register your account"
|
||||||
echo " Configure integrations via Settings or your AI agent"
|
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}"
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue