Deliver Step 9: agent-card.json, SKILL.md, USDA lookup, integrations dynamic config, Welcome/Integrations/MealPlan pages, API methods, README, routes
This commit is contained in:
parent
6fb5d759ef
commit
c133a463ec
10 changed files with 751 additions and 33 deletions
85
README.md
85
README.md
|
|
@ -1,42 +1,61 @@
|
||||||
# Fitness Rewards
|
# Diligence — Self-Hosted Fitness Rewards with AI Agent Support
|
||||||
|
|
||||||
Personal fitness web app with a points-based behavioral economy. Earn points through fitness activities, spend them on real-world rewards.
|
Points-based fitness accountability app. Log workouts and food, earn points, unlock rewards. Connect any AI agent via MCP for logging, coaching, and meal planning. Self-hosted, open source, zero cloud dependencies.
|
||||||
|
|
||||||
## Stack
|
Built by [DiligenceWorks](https://diligenceworks.online).
|
||||||
- **Backend**: FastAPI + PostgreSQL + SQLAlchemy (async)
|
|
||||||
- **Frontend**: React + Vite
|
|
||||||
- **Deployment**: Docker Compose via Coolify
|
|
||||||
|
|
||||||
## Quick Start (Development)
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
git clone https://github.com/diligenceworks/diligence
|
||||||
cd backend
|
cd diligence
|
||||||
python -m venv .venv && source .venv/bin/activate
|
./setup.sh
|
||||||
pip install -r requirements.txt
|
docker compose up -d
|
||||||
uvicorn app.main:app --reload
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deploy (Production)
|
Open http://localhost and create your account.
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with real secrets
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Science-based onboarding (PAR-Q+, TTM Stages of Change, BREQ-2)
|
|
||||||
- Points economy with configurable earning rules and rewards
|
- **Points economy** — earn from workouts, food logging, step goals. Daily gate locks rewards until you earn enough. Weekly reset.
|
||||||
- Daily gate: earn your daily minimum before unlocking rewards
|
- **Science-based onboarding** — PAR-Q+ safety screening, TTM stages of change, BREQ-2 motivation profiling.
|
||||||
- Weekly targets with reset
|
- **AI agent integration** — 14 MCP tools for logging, coaching, meal planning, and device configuration.
|
||||||
- Strava + Polar integration (OAuth 2.0)
|
- **Meal plans** — AI-generated plans with compliance tracking and points integration.
|
||||||
- Open Food Facts barcode scanning for food logging
|
- **Activity sync** — Strava, Polar (OAuth 2.0). Garmin, Fitbit, Withings, WHOOP, Oura configurable in-app.
|
||||||
- Curated external fitness resources (Darebee, StrongLifts, YouTube)
|
- **Food search** — Open Food Facts (4M+ products) + USDA FoodData Central (400K+ research-grade).
|
||||||
- 90-day program commitment tracking
|
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
|
||||||
- Mobile-first PWA design
|
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
|
||||||
|
|
||||||
|
## Connecting an AI Agent
|
||||||
|
|
||||||
|
Point your agent's MCP config to `http://localhost:3001/sse` (development) or `https://your-domain/mcp` (production behind reverse proxy).
|
||||||
|
|
||||||
|
Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
|
||||||
|
|
||||||
|
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
|
||||||
|
|
||||||
|
## Configuring Integrations
|
||||||
|
|
||||||
|
All integrations are configured through the app UI (Settings → Integrations) or through your AI agent. No `.env` file editing or container restarts needed.
|
||||||
|
|
||||||
|
## Data Sovereignty
|
||||||
|
|
||||||
|
Diligence is self-hosted software. Your data never leaves your server. No cloud dependency, no vendor lock-in, no telemetry. MIT license — fork it, modify it, keep it forever.
|
||||||
|
|
||||||
|
Your fitness data — heart rate, body composition, food intake, GPS traces — is biometric data that deserves sovereignty.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Python 3.12, FastAPI, SQLAlchemy
|
||||||
|
- React 18, Vite
|
||||||
|
- PostgreSQL 16
|
||||||
|
- FastMCP (Streamable HTTP/SSE)
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions welcome. Open an issue to discuss before submitting a PR.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -153,3 +153,117 @@ async def disconnect(
|
||||||
if token:
|
if token:
|
||||||
await db.delete(token)
|
await db.delete(token)
|
||||||
return {"status": "disconnected", "provider": provider}
|
return {"status": "disconnected", "provider": provider}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Dynamic Integration Config (v3) ---
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.models.integration_config import IntegrationConfig
|
||||||
|
from app.services.provider_registry import PROVIDER_REGISTRY
|
||||||
|
from app.services.crypto import encrypt_value, decrypt_value
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureRequest(BaseModel):
|
||||||
|
provider: str
|
||||||
|
credentials: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def full_integration_status(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Return connection status for all providers. Never returns actual credentials."""
|
||||||
|
# OAuth tokens (Strava, Polar)
|
||||||
|
oauth_result = await db.execute(select(OAuthToken).where(OAuthToken.user_id == user.id))
|
||||||
|
oauth_tokens = {t.provider: True for t in oauth_result.scalars().all()}
|
||||||
|
|
||||||
|
# Dynamic config entries
|
||||||
|
config_result = await db.execute(
|
||||||
|
select(IntegrationConfig.provider)
|
||||||
|
.where(IntegrationConfig.user_id == user.id)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
configured_providers = {row[0] for row in config_result.all()}
|
||||||
|
|
||||||
|
# Also check env vars for backward compatibility
|
||||||
|
env_providers = set()
|
||||||
|
if settings.strava_client_id:
|
||||||
|
env_providers.add("strava")
|
||||||
|
if settings.polar_client_id:
|
||||||
|
env_providers.add("polar")
|
||||||
|
if settings.telegram_bot_token:
|
||||||
|
env_providers.add("telegram")
|
||||||
|
if settings.groq_api_key:
|
||||||
|
env_providers.add("groq")
|
||||||
|
|
||||||
|
status = {}
|
||||||
|
for key, info in PROVIDER_REGISTRY.items():
|
||||||
|
if key in oauth_tokens:
|
||||||
|
status[key] = "connected"
|
||||||
|
elif key in configured_providers or key in env_providers:
|
||||||
|
status[key] = "configured"
|
||||||
|
else:
|
||||||
|
status[key] = "not_configured"
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers")
|
||||||
|
async def list_providers():
|
||||||
|
"""Return the provider registry with setup instructions."""
|
||||||
|
result = {}
|
||||||
|
for key, info in PROVIDER_REGISTRY.items():
|
||||||
|
result[key] = {
|
||||||
|
"name": info["name"],
|
||||||
|
"type": info["type"],
|
||||||
|
"fields": info["fields"],
|
||||||
|
"help_url": info.get("help_url", ""),
|
||||||
|
"help_text": info.get("help_text", "").replace("{BASE_URL}", settings.base_url),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/configure")
|
||||||
|
async def configure_integration(
|
||||||
|
body: ConfigureRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Store encrypted integration credentials. Write-only — values can never be read back."""
|
||||||
|
if body.provider not in PROVIDER_REGISTRY:
|
||||||
|
raise HTTPException(400, f"Unknown provider: {body.provider}")
|
||||||
|
|
||||||
|
info = PROVIDER_REGISTRY[body.provider]
|
||||||
|
expected_fields = set(info["fields"])
|
||||||
|
provided_fields = set(body.credentials.keys())
|
||||||
|
|
||||||
|
missing = expected_fields - provided_fields
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(400, f"Missing required fields: {', '.join(missing)}")
|
||||||
|
|
||||||
|
for key, value in body.credentials.items():
|
||||||
|
encrypted = encrypt_value(settings.secret_key, value)
|
||||||
|
|
||||||
|
# Upsert
|
||||||
|
existing = await db.execute(
|
||||||
|
select(IntegrationConfig).where(
|
||||||
|
IntegrationConfig.user_id == user.id,
|
||||||
|
IntegrationConfig.provider == body.provider,
|
||||||
|
IntegrationConfig.config_key == key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = existing.scalar_one_or_none()
|
||||||
|
if row:
|
||||||
|
row.config_value = encrypted
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
db.add(IntegrationConfig(
|
||||||
|
user_id=user.id,
|
||||||
|
provider=body.provider,
|
||||||
|
config_key=key,
|
||||||
|
config_value=encrypted,
|
||||||
|
))
|
||||||
|
|
||||||
|
return {"status": "configured", "provider": body.provider}
|
||||||
|
|
|
||||||
59
backend/app/services/usda_lookup.py
Normal file
59
backend/app/services/usda_lookup.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""USDA FoodData Central lookup service.
|
||||||
|
|
||||||
|
Free API with 400K+ foods and research-grade nutrition data.
|
||||||
|
API key required (free, instant signup at https://fdc.nal.usda.gov/api-key-signup).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USDA_BASE = "https://api.nal.usda.gov/fdc/v1"
|
||||||
|
|
||||||
|
|
||||||
|
async def search_usda(query: str, api_key: str, page_size: int = 10) -> list[dict]:
|
||||||
|
"""Search USDA FoodData Central for food items.
|
||||||
|
|
||||||
|
Returns a list of dicts with: fdcId, description, brandName, calories,
|
||||||
|
protein_g, carbs_g, fat_g, serving_size.
|
||||||
|
"""
|
||||||
|
if not api_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{USDA_BASE}/foods/search",
|
||||||
|
params={
|
||||||
|
"api_key": api_key,
|
||||||
|
"query": query,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"dataType": "Foundation,SR Legacy,Branded",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for food in data.get("foods", []):
|
||||||
|
nutrients = {n["nutrientName"]: n["value"] for n in food.get("foodNutrients", [])}
|
||||||
|
results.append({
|
||||||
|
"fdcId": food.get("fdcId"),
|
||||||
|
"description": food.get("description", ""),
|
||||||
|
"brandName": food.get("brandName"),
|
||||||
|
"source": "usda",
|
||||||
|
"calories": nutrients.get("Energy"),
|
||||||
|
"protein_g": nutrients.get("Protein"),
|
||||||
|
"carbs_g": nutrients.get("Carbohydrate, by difference"),
|
||||||
|
"fat_g": nutrients.get("Total lipid (fat)"),
|
||||||
|
"fiber_g": nutrients.get("Fiber, total dietary"),
|
||||||
|
"serving_size": food.get("servingSize"),
|
||||||
|
"serving_unit": food.get("servingSizeUnit"),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"USDA search failed: {e}")
|
||||||
|
return []
|
||||||
102
frontend/public/.well-known/agent-card.json
Normal file
102
frontend/public/.well-known/agent-card.json
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
{
|
||||||
|
"name": "Diligence Fitness Rewards",
|
||||||
|
"description": "Self-hosted fitness rewards platform with points-based behavioral economy. Your fitness data stays on your hardware. AI agents can log activities, query progress, manage meal plans, and configure device integrations via MCP.",
|
||||||
|
"url": "{BASE_URL}/mcp",
|
||||||
|
"provider": {
|
||||||
|
"organization": "DiligenceWorks Pte. Ltd.",
|
||||||
|
"url": "https://diligenceworks.online"
|
||||||
|
},
|
||||||
|
"version": "1.0.0",
|
||||||
|
"documentationUrl": "https://github.com/diligenceworks/diligence",
|
||||||
|
"capabilities": {
|
||||||
|
"streaming": true,
|
||||||
|
"pushNotifications": false,
|
||||||
|
"stateTransitionHistory": false
|
||||||
|
},
|
||||||
|
"authentication": {
|
||||||
|
"schemes": ["apiKey"],
|
||||||
|
"credentials": null
|
||||||
|
},
|
||||||
|
"defaultInputModes": ["text/plain", "application/json"],
|
||||||
|
"defaultOutputModes": ["application/json"],
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"id": "context-status",
|
||||||
|
"name": "Context & Status",
|
||||||
|
"description": "Get full user profile with motivation type (BREQ-2), active program, points status, rewards, and integration connections. Includes get_context (full brief), get_today (daily points/gate), and get_week (weekly summary).",
|
||||||
|
"tags": ["fitness", "status", "profile", "motivation"],
|
||||||
|
"examples": [
|
||||||
|
"How am I doing today?",
|
||||||
|
"What's my weekly progress?",
|
||||||
|
"Show me my fitness dashboard"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "activity-logging",
|
||||||
|
"name": "Activity Logging",
|
||||||
|
"description": "Record workouts, runs, walks, cycling, yoga, or any physical activity. Awards points based on activity type and duration. Supports structured program workouts via program_day parameter.",
|
||||||
|
"tags": ["fitness", "tracking", "points", "workout"],
|
||||||
|
"examples": [
|
||||||
|
"I just did a 30-minute run",
|
||||||
|
"Log my StrongLifts Workout B today",
|
||||||
|
"I did 45 minutes of yoga this morning"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nutrition-tracking",
|
||||||
|
"name": "Food & Nutrition",
|
||||||
|
"description": "Log food intake with calorie and macro tracking. Search Open Food Facts (4M+ products) and USDA FoodData Central (400K+ research-grade items). Awards points for consistent food logging.",
|
||||||
|
"tags": ["nutrition", "food", "tracking", "barcode", "search"],
|
||||||
|
"examples": [
|
||||||
|
"I had 2 eggs and toast for breakfast",
|
||||||
|
"Search for chicken breast nutrition",
|
||||||
|
"Log a salad for lunch, about 400 calories"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "program-schedule",
|
||||||
|
"name": "Program Schedule",
|
||||||
|
"description": "View today's scheduled workout from the active program including exercises, sets, reps, and weight progression. Shows upcoming workouts and overall program completion percentage.",
|
||||||
|
"tags": ["program", "schedule", "workout", "planning"],
|
||||||
|
"examples": [
|
||||||
|
"What's my workout today?",
|
||||||
|
"Show me this week's program schedule",
|
||||||
|
"How far through my program am I?"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rewards",
|
||||||
|
"name": "Rewards",
|
||||||
|
"description": "View available rewards with point costs and spend earned points. Rewards are user-configurable (gaming time, screen time, treats, etc.). Points gate must be passed before redemption.",
|
||||||
|
"tags": ["rewards", "points", "spending", "gamification"],
|
||||||
|
"examples": [
|
||||||
|
"What rewards can I unlock?",
|
||||||
|
"Spend points on 30 minutes of gaming",
|
||||||
|
"Do I have enough points for a movie?"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meal-planning",
|
||||||
|
"name": "Meal Planning",
|
||||||
|
"description": "Create, view, and track AI-generated meal plans. The agent generates plan content; the app stores and tracks compliance. Includes load_meal_plan, get_meal_plan, update_meal_compliance, and get_plan_progress.",
|
||||||
|
"tags": ["nutrition", "meal-plan", "diet", "compliance"],
|
||||||
|
"examples": [
|
||||||
|
"Create a 7-day keto meal plan",
|
||||||
|
"What am I supposed to eat today?",
|
||||||
|
"I followed my breakfast plan",
|
||||||
|
"How's my meal plan compliance?"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device-integration",
|
||||||
|
"name": "Device Integration",
|
||||||
|
"description": "Configure connections to fitness devices and services (Strava, Polar, Garmin, Fitbit, WHOOP, Oura, Withings). Write-only credential storage — agent can set but never read credentials back.",
|
||||||
|
"tags": ["integration", "strava", "garmin", "fitbit", "sync"],
|
||||||
|
"examples": [
|
||||||
|
"Connect my Strava account",
|
||||||
|
"What integrations are available?",
|
||||||
|
"I want to set up my Garmin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Diligence — Agent Skill Definition
|
||||||
|
|
||||||
|
## What Is Diligence?
|
||||||
|
|
||||||
|
Diligence is a self-hosted fitness rewards platform. Users earn points through fitness activities (workouts, food logging, step goals) and spend them on configurable rewards (gaming time, screen time, etc.). A daily gate keeps rewards locked until enough points are earned. Points reset weekly.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Backend:** FastAPI (Python 3.12) + PostgreSQL 16
|
||||||
|
- **Frontend:** React 18 + Vite, served by nginx
|
||||||
|
- **MCP Connector:** FastMCP (Streamable HTTP/SSE) on port 3001, proxied via nginx at `/mcp`
|
||||||
|
- **Deployment:** Docker Compose (4 services: frontend, backend, mcp-connector, fitness-db)
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
|
||||||
|
Point your MCP client to:
|
||||||
|
- **Development:** `http://localhost:3001/sse`
|
||||||
|
- **Production:** `https://your-domain/mcp`
|
||||||
|
|
||||||
|
## Available Tools (14)
|
||||||
|
|
||||||
|
| Tool | Category | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| `get_context()` | Status | Full profile, motivation, program, rules, rewards |
|
||||||
|
| `get_today(date?)` | Status | Daily points, gate status, activities |
|
||||||
|
| `get_week(start_date?)` | Status | Weekly summary, active days |
|
||||||
|
| `log_activity(category, title, duration_minutes, ...)` | Activity | Log workout, earn points |
|
||||||
|
| `log_food(meal_type, food_name, ...)` | Food | Log food with macros |
|
||||||
|
| `search_food(query)` | Food | Search Open Food Facts + USDA |
|
||||||
|
| `get_program_schedule(date?)` | Programs | Today's workout from active program |
|
||||||
|
| `redeem_reward(reward_name)` | Rewards | Spend points on a reward |
|
||||||
|
| `load_meal_plan(name, duration_days, meals, ...)` | Meal Plans | Create a full meal plan |
|
||||||
|
| `get_meal_plan(date?)` | Meal Plans | View today's planned meals |
|
||||||
|
| `update_meal_compliance(plan_item_id, status, ...)` | Meal Plans | Mark meal followed/skipped |
|
||||||
|
| `get_plan_progress(plan_id?)` | Meal Plans | Compliance statistics |
|
||||||
|
| `configure_integration(provider, credentials)` | Integrations | Store encrypted credentials (write-only) |
|
||||||
|
| `get_integration_status()` | Integrations | Check provider connection status |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- The **agent generates meal plans** — the app only stores and tracks them. Zero AI API cost.
|
||||||
|
- Integration credentials are **write-only through MCP** — agents can set but never read them back.
|
||||||
|
- The app collects **BREQ-2 motivation data** during onboarding. Use `get_context()` to read the user's motivation profile and calibrate tone accordingly.
|
||||||
|
- Points reset weekly. Each week is a fresh start. No guilt carry-over.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- **Repository:** https://github.com/diligenceworks/diligence
|
||||||
|
- **License:** MIT
|
||||||
|
- **Built by:** DiligenceWorks Pte. Ltd. (https://diligenceworks.online)
|
||||||
|
|
@ -10,6 +10,9 @@ import Rewards from './pages/Rewards'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import Onboarding from './pages/Onboarding'
|
import Onboarding from './pages/Onboarding'
|
||||||
import WeekView from './pages/WeekView'
|
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 ProgramSearch from './pages/ProgramSearch'
|
||||||
import ProgramDetail from './pages/ProgramDetail'
|
import ProgramDetail from './pages/ProgramDetail'
|
||||||
import CatalogDetail from './pages/CatalogDetail'
|
import CatalogDetail from './pages/CatalogDetail'
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,23 @@ export const api = {
|
||||||
polarSync: () => request('/integrations/polar/sync', { method: 'POST' }),
|
polarSync: () => request('/integrations/polar/sync', { method: 'POST' }),
|
||||||
disconnect: (provider) => request(`/integrations/${provider}`, { method: 'DELETE' }),
|
disconnect: (provider) => request(`/integrations/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
|
||||||
|
// Meal Plans (v3)
|
||||||
|
listMealPlans: () => request('/meal-plans'),
|
||||||
|
createMealPlan: (data) => request('/meal-plans', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
getMealPlanToday: () => request('/meal-plans/today'),
|
||||||
|
getMealPlan: (id) => request('/meal-plans/' + id),
|
||||||
|
updateMealPlanStatus: (id, status) =>
|
||||||
|
request('/meal-plans/' + id, { method: 'PATCH', body: JSON.stringify({ status }) }),
|
||||||
|
logMealCompliance: (data) =>
|
||||||
|
request('/meal-plans/compliance', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
getMealPlanProgress: (id) => request('/meal-plans/' + id + '/progress'),
|
||||||
|
|
||||||
|
// Dynamic Integrations (v3)
|
||||||
|
fullIntegrationStatus: () => request('/integrations/status'),
|
||||||
|
listProviders: () => request('/integrations/providers'),
|
||||||
|
configureIntegration: (provider, credentials) =>
|
||||||
|
request('/integrations/configure', { method: 'POST', body: JSON.stringify({ provider, credentials }) }),
|
||||||
// Resources
|
// Resources
|
||||||
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
156
frontend/src/pages/MealPlan.jsx
Normal file
156
frontend/src/pages/MealPlan.jsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const MEAL_ICONS = { breakfast: '🌅', lunch: '🌞', dinner: '🌙', snack: '🍎' }
|
||||||
|
|
||||||
|
export default function MealPlan() {
|
||||||
|
const [plan, setPlan] = useState(null)
|
||||||
|
const [plans, setPlans] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [compliance, setCompliance] = useState({}) // plan_item_id → status
|
||||||
|
|
||||||
|
useEffect(() => { loadAll() }, [])
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
try {
|
||||||
|
const [today, all] = await Promise.all([api.getMealPlanToday(), api.listMealPlans()])
|
||||||
|
setPlan(today)
|
||||||
|
setPlans(all)
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markCompliance(itemId, status) {
|
||||||
|
try {
|
||||||
|
await api.logMealCompliance({ plan_item_id: itemId, status })
|
||||||
|
setCompliance(prev => ({ ...prev, [itemId]: status }))
|
||||||
|
} catch (err) { alert(`Failed: ${err.message}`) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||||
|
<h1 style={{ fontSize: '1.4rem', fontWeight: 700, marginBottom: 8 }}>Meal Plan</h1>
|
||||||
|
|
||||||
|
{!plan?.active_plan ? (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 32,
|
||||||
|
textAlign: 'center', color: 'var(--text-2)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: 12 }}>🍽️</div>
|
||||||
|
<p style={{ marginBottom: 8 }}>No active meal plan.</p>
|
||||||
|
<p style={{ fontSize: '0.85rem' }}>
|
||||||
|
Ask your AI agent to create one: "Generate a 7-day keto meal plan for me"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
marginBottom: 20, color: 'var(--text-2)', fontSize: '0.9rem',
|
||||||
|
}}>
|
||||||
|
<span>{plan.active_plan}</span>
|
||||||
|
<span style={{
|
||||||
|
background: 'var(--accent)', color: '#fff', padding: '3px 10px',
|
||||||
|
borderRadius: 20, fontSize: '0.75rem', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
Day {plan.day} / {plan.duration_days}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.daily_calories && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--surface-2)', borderRadius: 'var(--r-sm)', padding: 12,
|
||||||
|
marginBottom: 16, textAlign: 'center', fontSize: '0.85rem', color: 'var(--text-2)',
|
||||||
|
}}>
|
||||||
|
Target: {plan.daily_calories} cal · {plan.diet_type || 'balanced'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan.meals?.length > 0 ? (
|
||||||
|
plan.meals.map(meal => {
|
||||||
|
const itemStatus = compliance[meal.id]
|
||||||
|
return (
|
||||||
|
<div key={meal.id} style={{
|
||||||
|
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 16,
|
||||||
|
marginBottom: 10,
|
||||||
|
borderLeft: itemStatus === 'followed' ? '4px solid #4CAF50' :
|
||||||
|
itemStatus === 'skipped' ? '4px solid #F44336' :
|
||||||
|
itemStatus === 'substituted' ? '4px solid #FF9800' : '4px solid transparent',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', textTransform: 'uppercase', marginBottom: 4 }}>
|
||||||
|
{MEAL_ICONS[meal.meal_type] || '🍽️'} {meal.meal_type}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{meal.food_name}</div>
|
||||||
|
{meal.description && (
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-2)' }}>{meal.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{meal.calories && (
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-2)', flexShrink: 0, marginLeft: 12 }}>
|
||||||
|
{meal.calories} cal
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meal.protein_g && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', marginTop: 6 }}>
|
||||||
|
P: {meal.protein_g}g · C: {meal.carbs_g}g · F: {meal.fat_g}g
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!itemStatus && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
|
||||||
|
{['followed', 'substituted', 'skipped'].map(s => (
|
||||||
|
<button key={s} onClick={() => markCompliance(meal.id, s)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: '1px solid var(--border)',
|
||||||
|
padding: '4px 12px', borderRadius: 'var(--r-sm)', fontSize: '0.75rem',
|
||||||
|
cursor: 'pointer', color: 'var(--text-2)', textTransform: 'capitalize',
|
||||||
|
}}>
|
||||||
|
{s === 'followed' ? '✅' : s === 'skipped' ? '⏭️' : '🔄'} {s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--text-2)', textAlign: 'center' }}>{plan.message || 'No meals for today.'}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plans.length > 0 && (
|
||||||
|
<div style={{ marginTop: 32 }}>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 12 }}>All Plans</h2>
|
||||||
|
{plans.map(p => (
|
||||||
|
<div key={p.id} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '10px 0', borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{p.name}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
|
||||||
|
{p.diet_type} · {p.duration_days} days · started {p.start_date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem', padding: '2px 8px', borderRadius: 12,
|
||||||
|
background: p.status === 'active' ? '#4CAF5022' : '#9E9E9E22',
|
||||||
|
color: p.status === 'active' ? '#4CAF50' : '#9E9E9E',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
frontend/src/pages/SettingsIntegrations.jsx
Normal file
137
frontend/src/pages/SettingsIntegrations.jsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
connected: '#4CAF50',
|
||||||
|
configured: '#FF9800',
|
||||||
|
not_configured: '#9E9E9E',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
connected: 'Connected',
|
||||||
|
configured: 'Configured',
|
||||||
|
not_configured: 'Not configured',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsIntegrations() {
|
||||||
|
const [providers, setProviders] = useState({})
|
||||||
|
const [status, setStatus] = useState({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [configuring, setConfiguring] = useState(null) // provider key being configured
|
||||||
|
const [formData, setFormData] = useState({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { loadAll() }, [])
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
try {
|
||||||
|
const [p, s] = await Promise.all([api.listProviders(), api.fullIntegrationStatus()])
|
||||||
|
setProviders(p)
|
||||||
|
setStatus(s)
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startConfigure(key) {
|
||||||
|
setConfiguring(key)
|
||||||
|
const fields = providers[key]?.fields || []
|
||||||
|
setFormData(Object.fromEntries(fields.map(f => [f, ''])))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(key) {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.configureIntegration(key, formData)
|
||||||
|
setConfiguring(null)
|
||||||
|
await loadAll()
|
||||||
|
} catch (err) { alert(`Failed: ${err.message}`) }
|
||||||
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: 700, margin: '0 auto' }}>
|
||||||
|
<h1 style={{ fontSize: '1.4rem', fontWeight: 700, marginBottom: 24 }}>Integrations</h1>
|
||||||
|
<p style={{ color: 'var(--text-2)', marginBottom: 24, fontSize: '0.9rem' }}>
|
||||||
|
Configure your fitness devices and services. Credentials are encrypted and stored locally — never sent to any external server.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{Object.entries(providers).map(([key, info]) => (
|
||||||
|
<div key={key} style={{
|
||||||
|
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 20, marginBottom: 12,
|
||||||
|
border: status[key] === 'connected' ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<strong style={{ fontSize: '1rem' }}>{info.name}</strong>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem', padding: '3px 10px', borderRadius: 20, fontWeight: 600,
|
||||||
|
background: STATUS_COLORS[status[key] || 'not_configured'] + '22',
|
||||||
|
color: STATUS_COLORS[status[key] || 'not_configured'],
|
||||||
|
}}>
|
||||||
|
{STATUS_LABELS[status[key] || 'not_configured']}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ color: 'var(--text-2)', fontSize: '0.85rem', margin: '0 0 12px' }}>
|
||||||
|
{info.help_text}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{info.help_url && (
|
||||||
|
<a href={info.help_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: '0.8rem', color: 'var(--accent)' }}>
|
||||||
|
Developer portal →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{configuring === key ? (
|
||||||
|
<div style={{ marginTop: 12, padding: 16, background: 'var(--surface)', borderRadius: 'var(--r-sm)' }}>
|
||||||
|
{info.fields.map(field => (
|
||||||
|
<div key={field} style={{ marginBottom: 10 }}>
|
||||||
|
<label style={{ fontSize: '0.8rem', fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||||
|
{field.replace(/_/g, ' ')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={field.includes('secret') || field.includes('token') || field.includes('key') ? 'password' : 'text'}
|
||||||
|
value={formData[field] || ''}
|
||||||
|
onChange={e => setFormData({ ...formData, [field]: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)', fontSize: '0.9rem', boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
placeholder={`Enter ${field.replace(/_/g, ' ')}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
<button onClick={() => handleSave(key)} disabled={saving}
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)', color: '#fff', border: 'none',
|
||||||
|
padding: '8px 20px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfiguring(null)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', color: 'var(--text-2)', border: '1px solid var(--border)',
|
||||||
|
padding: '8px 20px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => startConfigure(key)}
|
||||||
|
style={{
|
||||||
|
marginTop: 8, background: 'transparent', color: 'var(--accent)',
|
||||||
|
border: '1px solid var(--accent)', padding: '6px 16px', borderRadius: 'var(--r-sm)',
|
||||||
|
cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{status[key] === 'not_configured' ? 'Configure' : 'Reconfigure'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
frontend/src/pages/Welcome.jsx
Normal file
61
frontend/src/pages/Welcome.jsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function Welcome() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: 600, margin: '0 auto', padding: '40px 20px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '3rem', marginBottom: 16 }}>💪</div>
|
||||||
|
<h1 style={{ fontSize: '1.8rem', fontWeight: 700, marginBottom: 8 }}>Earn Before You Spend</h1>
|
||||||
|
<p style={{ color: 'var(--text-2)', fontSize: '1rem', lineHeight: 1.6, marginBottom: 32 }}>
|
||||||
|
This app runs on a simple idea: you earn points by doing healthy things
|
||||||
|
(workouts, logging meals, hitting step goals), and you spend points on
|
||||||
|
guilty pleasures you configure yourself.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 24, marginBottom: 24, textAlign: 'left' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: '1.5rem' }}>🔒</span>
|
||||||
|
<div>
|
||||||
|
<strong>Daily Gate</strong>
|
||||||
|
<p style={{ color: 'var(--text-2)', margin: '4px 0 0', fontSize: '0.9rem' }}>
|
||||||
|
Until you earn enough points today, your rewards stay locked. Hit your target, and the gate opens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: '1.5rem' }}>🎮</span>
|
||||||
|
<div>
|
||||||
|
<strong>Your Rules</strong>
|
||||||
|
<p style={{ color: 'var(--text-2)', margin: '4px 0 0', fontSize: '0.9rem' }}>
|
||||||
|
You set the rewards — gaming time, takeout, screen time, whatever you want. You hold yourself accountable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<span style={{ fontSize: '1.5rem' }}>🤖</span>
|
||||||
|
<div>
|
||||||
|
<strong>AI Agent (Optional)</strong>
|
||||||
|
<p style={{ color: 'var(--text-2)', margin: '4px 0 0', fontSize: '0.9rem' }}>
|
||||||
|
Connect an AI agent to log activities by voice, get nudged when you're behind, and have a fitness companion that knows your goals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)', color: '#fff', border: 'none', borderRadius: 'var(--r-sm)',
|
||||||
|
padding: '14px 32px', fontSize: '1rem', fontWeight: 600, cursor: 'pointer', width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style={{ color: 'var(--text-3)', fontSize: '0.8rem', marginTop: 16 }}>
|
||||||
|
Points reset weekly. Each week is a fresh start.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue