Compare commits

...

7 commits

Author SHA1 Message Date
Claude
ad3da57174 Fix: add backwards-compat CSS aliases for removed variable names
Theme overhaul removed --surface, --surface-2, --border, --r-md but
SettingsIntegrations.jsx and other pages still reference them. Cards
were rendering but invisible (transparent background/border).

Added aliases: --surface/--surface-2 → --card, --border → --card-border,
--r-md → --r
2026-07-13 22:20:08 +00:00
Claude
2411fc5b99 docs: add beta tester guide with Claude Desktop setup instructions 2026-07-05 01:13:18 +00:00
Claude
c37543b009 feat: timezone auto-detection, branding fix, frontend rebuild
Timezone:
- Add diligence/utils/dates.py with today_for_user(), now_for_user(), day_start_utc()
- Replace all 20 date.today() calls across 10 files with timezone-aware helpers
- Remove hardcoded Asia/Bangkok offset hack in nutrition router
- Change defaults from Asia/Bangkok to UTC in user model, nutrition model, config
- Add timezone field to RegisterRequest schema
- Set user.timezone from browser detection during registration
- Frontend sends Intl.DateTimeFormat().resolvedOptions().timeZone on register

Branding:
- Login page: Fitness Rewards -> Diligence, updated tagline and emoji
- index.html title: Fitness Rewards -> Diligence

Frontend rebuild:
- Fresh Vite build with all changes (index-CsKoBN0D.js)
- Nav bar, timezone detection, and branding all verified in built bundle
2026-07-04 07:16:23 +00:00
Claude
40c8485aa7 feat: MCP server for pip install path
- diligence/mcp/server.py: 14 MCP tools (adapted from mcp-connector/)
- diligence/mcp/__init__.py: background thread startup
- CLI starts MCP on port 3001 automatically (--no-mcp to disable)
- Graceful fallback if mcp package not installed
- Agent config endpoint returns correct port for pip path
2026-07-01 04:54:56 +00:00
Claude
df39a8dc08 feat: Agent Connect page + /api/agent/config endpoint
- New AgentConnect.jsx page with MCP URL, token, setup instructions
- Claude Desktop config snippet with copy button
- Claude Code CLI command with copy button
- Agent capabilities overview
- Added to bottom nav bar (6th item, plug icon)
- Backend /api/agent/config endpoint returns deployment-aware MCP details
- Nav padding adjusted for 6 items
- Frontend rebuilt with new page
2026-07-01 03:45:34 +00:00
Claude
a6e6e2f54f v2.0: dual deployment — pip install (SQLite) + Docker (PostgreSQL)
Architecture:
- Moved backend/app/ to diligence/ Python package
- Dialect-agnostic models (Uuid + JSON, no PostgreSQL imports)
- Auto-detect database: empty DATABASE_URL → SQLite at ~/.diligence/data.db
- Cross-dialect migrations (inspector-based, no raw PostgreSQL DDL)
- FastAPI serves pre-built React frontend for pip path
- CLI entry point: diligence [--port] [--no-browser] [--data-dir]
- pyproject.toml with optional deps: [postgres], [mcp], [dev]
- Docker path unchanged: docker compose up -d

pip install path: pip install . && diligence
Docker path: ./setup.sh && docker compose up -d
2026-07-01 03:30:12 +00:00
Claude
0ce1463de8 Fix setup.ps1: correct execution order and add API_TOKEN generation
Bug: API_TOKEN replacement ran before .env existed, and before the
existence check. On a clean clone the script errored immediately.

Fixed flow: banner → existence check → generate SECRET_KEY →
generate API_TOKEN → copy .env.example → replace both keys.

Also adds MCP connection info to output (matching setup.sh).
2026-06-26 23:37:11 +00:00
89 changed files with 4280 additions and 1061 deletions

View file

@ -1,19 +1,23 @@
# === REQUIRED (generated automatically by setup.sh) ===
SECRET_KEY=
# Diligence Configuration
# Copy this to .env and fill in values, or run ./setup.sh to auto-generate.
# Required — auto-generated by setup.sh
SECRET_KEY=change-me-in-production
API_TOKEN=
# === APP URL (change for production) ===
BASE_URL=http://localhost
# Database — leave empty for SQLite (pip install path)
# Set for PostgreSQL (Docker path): postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
DATABASE_URL=
# === DATABASE (defaults work out of the box) ===
DB_USER=fitness
DB_NAME=fitness_rewards
# App
BASE_URL=http://localhost:8000
# === MCP AGENT AUTH (generated automatically by setup.sh) ===
# The API_TOKEN authenticates the MCP connector with the backend.
# It is auto-configured — you don't need to touch it unless you're
# connecting an agent from outside Docker.
# Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
# No container restart needed.
# Optional integrations
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
POLAR_CLIENT_ID=
POLAR_CLIENT_SECRET=
GROQ_API_KEY=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
CRAWL_ENABLED=false

31
.gitignore vendored
View file

@ -1,12 +1,31 @@
# Environment
.env
*.env.local
# Python
__pycache__/
*.py[cod]
*.egg-info/
.env
.venv/
node_modules/
dist/
.DS_Store
*.log
build/
*.egg
# Excluded from open-source (scraped third-party content)
# Node
frontend/node_modules/
frontend/dist/
# Data
*.db
*.sqlite
*.sqlite3
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Content (scraped recipes — not for distribution)
content/

376
README.md
View file

@ -1,237 +1,211 @@
# Diligence — Self-Hosted Fitness Rewards with AI Agent Support
# Diligence
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.
Self-hosted fitness rewards platform with AI agent integration. Points-based behavioral economy: earn points through workouts and food logging, spend them on rewards you choose. Science-based onboarding. 14-tool MCP connector for AI agents. Your data stays on your machine.
Built by [DiligenceWorks](https://diligenceworks.online).
## Quick Start
### Prerequisites
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) — that's it. No Python, Node.js, or database install needed.
- Docker Desktop runs natively on **Windows 10/11**, **macOS**, and **Linux**.
**By [DiligenceWorks Pte. Ltd.](https://diligenceworks.online) — MIT License**
---
### Windows 11 Setup
## Which install is right for you?
**1. Install Docker Desktop**
| | **pip install** | **Docker** |
|---|---|---|
| **Who it's for** | You want a fitness app on your laptop. No servers, no DevOps, no fuss. | You're self-hosting for a household, a team, or you want PostgreSQL and a production-grade setup. |
| **What you need** | Python 3.11+ | Docker and Docker Compose |
| **Database** | SQLite (zero config, file on disk) | PostgreSQL 16 (runs in a container) |
| **Runs as** | Single process on localhost | 4 containers behind nginx |
| **Setup time** | 2 minutes | 5 minutes |
| **Best for** | Personal use on a laptop or desktop | Always-on server, multiple users, backups |
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.
## Install — Personal Laptop (pip)
- **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.
**Prerequisites:** Python 3.11 or newer.
**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).
```bash
# Clone and install
git clone https://github.com/DiligenceWorks/Diligence.git
cd Diligence
pip install .
**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
# Run
diligence
```
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
That's it. The app opens in your browser at `http://localhost:8000`. Your data lives in `~/.diligence/`.
On first run, Diligence generates a secret key and stores your config in `~/.diligence/.env`. The SQLite database is created automatically at `~/.diligence/data.db`.
### Options
```bash
diligence --port 9000 # Different port
diligence --no-browser # Don't auto-open browser
diligence --data-dir /my/path # Custom data directory
python -m diligence # Alternative way to run
```
### Building the frontend from source
The pip package includes a pre-built frontend. If you want to modify the UI:
```bash
cd frontend
npm install
npm run build
cp -r dist/ ../diligence/frontend/
```
---
### macOS Setup
## Install — Server (Docker)
**1. Install Docker Desktop**
**Prerequisites:** Docker and Docker Compose.
Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/). Make sure you pick the right installer for your chip:
- **Apple Silicon** (M1, M2, M3, M4) — download the Apple Silicon .dmg
- **Intel** — download the Intel .dmg
To check: Apple menu → About This Mac. Look for "Chip" (Apple Silicon) or "Processor" (Intel).
Drag Docker.app to Applications and launch it. Requires macOS 13 Ventura or later.
Alternatively, install via Homebrew: `brew install --cask docker`
**2. Clone and run**
Open Terminal:
```bash
git clone https://github.com/diligenceworks/diligence
cd diligence
./setup.sh
docker compose up -d
git clone https://github.com/DiligenceWorks/Diligence.git
cd Diligence
./setup.sh # generates .env with secrets
docker compose up -d # starts 4 containers
```
Open `http://localhost` (or your server's IP). Register your account — the first user gets admin.
### What's running
| Container | Role | Port |
|-----------|------|------|
| frontend | React app + nginx reverse proxy | 80 |
| backend | FastAPI application server | 8000 (internal) |
| mcp-connector | MCP SSE server for AI agents | 3001 |
| fitness-db | PostgreSQL 16 | 5432 (internal) |
### Environment variables
Copy `.env.example` to `.env` (or let `setup.sh` do it). Key variables:
| Variable | Docker default | Description |
|----------|---------------|-------------|
| `SECRET_KEY` | (generated) | JWT signing key |
| `API_TOKEN` | (generated) | MCP connector auth token |
| `DATABASE_URL` | `postgresql+asyncpg://...` | Database connection |
| `BASE_URL` | `http://localhost` | Public URL for OAuth callbacks |
---
## AI Agent Integration (MCP)
Diligence exposes 14 tools via the [Model Context Protocol](https://modelcontextprotocol.io). Any MCP-compatible AI agent (Claude, custom agents) can log workouts, track food, manage meal plans, and check progress.
### Connect your agent
**pip install path:**
```
URL: http://localhost:8000/mcp
Token: (shown on first run, or check ~/.diligence/.env)
```
**Docker path:**
```
URL: http://localhost:3001/sse
Token: (in your .env file, API_TOKEN)
```
### Available tools
| Tool | What it does |
|------|-------------|
| `get_context()` | Full profile, motivation type, programs, rules, rewards |
| `get_today()` | Daily points, gate status, activities |
| `get_week()` | Weekly summary and target progress |
| `log_activity(...)` | Log a workout, earn points |
| `log_food(...)` | Log food with macros |
| `search_food(query)` | Search Open Food Facts + USDA (400K+ foods) |
| `get_program_schedule()` | Today's scheduled workout |
| `redeem_reward(name)` | Spend points on a reward |
| `load_meal_plan(...)` | Create a meal plan |
| `get_meal_plan()` | View today's meals |
| `update_meal_compliance(...)` | Mark meals as followed/skipped |
| `get_plan_progress()` | Compliance stats |
| `configure_integration(...)` | Store encrypted credentials |
| `get_integration_status()` | Check provider connections |
See `AGENT_GUIDE.md` for behavioral guidelines including BREQ-2 tone calibration.
---
## What's inside
### Science-based onboarding
- **PAR-Q+** physical activity readiness screening
- **TTM** (Transtheoretical Model) stage assessment
- **BREQ-2** motivation profiling — the app adapts its tone to your motivation type
### Points engine
- Earn points for workouts, food logging, fasting, meal compliance
- Daily gate (minimum to unlock rewards)
- Weekly targets with reset
- Configurable reward shop — you decide what points are worth
### Integrations
Strava and Polar sync are built in. The provider registry supports Garmin, Fitbit, Withings, WHOOP, and Oura (OAuth flows ready, need provider approval). USDA FoodData Central for nutrition lookup.
---
## Project structure
```
Diligence/
pyproject.toml # pip install config
docker-compose.yml # Docker config
diligence/ # Python package
cli.py # Entry point for pip path
main.py # FastAPI app
config.py # Settings (auto-detects SQLite vs PostgreSQL)
database.py # Dialect-agnostic database layer
models/ # 15 SQLAlchemy models
routers/ # 12 API routers
services/ # Points engine, food lookup, sync, crypto
utils/ # Auth helpers
mcp/ # MCP connector (14 tools)
frontend/ # Pre-built React app (pip path serves this)
frontend/ # React source code
backend/ # Dockerfile + requirements.txt (Docker path)
mcp-connector/ # MCP Dockerfile (Docker path)
```
---
### Linux Setup
Install Docker Engine and Docker Compose via your distro's package manager, or install Docker Desktop for Linux. Then:
## Development
```bash
git clone https://github.com/diligenceworks/diligence
cd diligence
./setup.sh
docker compose up -d
# Clone
git clone https://github.com/DiligenceWorks/Diligence.git
cd Diligence
# Backend
pip install -e ".[dev]"
diligence --port 8000
# Frontend (separate terminal)
cd frontend
npm install
npm run dev
```
---
Open http://localhost and create your account. First user gets admin.
## Data and privacy
### Verify
Your data never leaves your machine. There is no telemetry, no analytics, no cloud sync. The database is a single file (`~/.diligence/data.db` for pip, a Docker volume for Docker). Back it up however you back up your files.
```bash
docker compose ps
```
Integration credentials (Strava, Polar, etc.) are encrypted at rest using Fernet with HKDF key derivation from your SECRET_KEY.
You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`, `fitness-db`.
## Features
- **Points economy** — earn from workouts, food logging, step goals. Daily gate locks rewards until you earn enough. Weekly reset.
- **Science-based onboarding** — PAR-Q+ safety screening, TTM stages of change, BREQ-2 motivation profiling.
- **AI agent integration** — 14 MCP tools for logging, coaching, meal planning, and device configuration.
- **Meal plans** — AI-generated plans with compliance tracking and points integration.
- **Activity sync** — Strava, Polar (OAuth 2.0). Garmin, Fitbit, Withings, WHOOP, Oura configurable in-app.
- **Food search** — Open Food Facts (4M+ products) + USDA FoodData Central (400K+ research-grade).
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
## Built-in AI Coach
Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab.
Supported providers (one API key, that's it):
| Provider | Free tier | What you get |
|----------|-----------|-------------|
| **OpenRouter** | 26 free models | 300+ models from every major provider, one key |
| **Hugging Face** | Yes | Thousands of open-source models |
| **Groq** | Yes | Ultra-fast Llama inference |
| **Ollama** | Local, free | Run any model on your own machine |
| **OpenAI** | No | GPT-4o, GPT-4o-mini |
| **Claude** | No | Claude Sonnet 4.6, Opus 4.8 |
| **Gemini** | Yes | Gemini 2.0 Flash, 2.5 Pro |
| **Custom** | — | Any OpenAI-compatible endpoint (vLLM, TGI, LiteLLM) |
The AI coach has access to your profile, points, program schedule, and motivation type. It can log workouts, search food, and create meal plans through the chat.
### Connecting external AI agents (MCP)
The MCP connector at port 3001 works with any MCP-compatible agent. The built-in chat and external agents coexist — use whichever fits your workflow.
**Claude Desktop** — add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"diligence": { "url": "http://localhost:3001/sse" }
}
}
```
**Claude Code** (CLI):
```bash
claude mcp add diligence --transport sse http://localhost:3001/sse
```
**Cursor** — add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"diligence": { "url": "http://localhost:3001/sse" }
}
}
```
**Windsurf** — add to MCP settings, same format as Cursor.
**COROS watch owners** — add both Diligence and COROS MCP servers to your agent. The agent bridges your watch data with your fitness log:
```json
{
"mcpServers": {
"diligence": { "url": "http://localhost:3001/sse" },
"coros": { "url": "https://your-coros-mcp-url/sse" }
}
}
```
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
## Configuring Integrations
All integrations are configured through the app UI (**Settings → Integrations**) or through your AI agent. No `.env` file editing or container restarts needed.
Tell your agent: *"I want to connect my Strava"* — it will walk you through getting API credentials and store them encrypted.
## 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.
## Architecture
```
┌─────────────────────────────────────────────────┐
│ nginx │
│ (frontend, /api proxy, /mcp proxy) │
├──────────┬─────────────────┬────────────────────┤
│ React │ FastAPI │ MCP Connector │
│ SPA │ Backend │ (14 tools) │
│ │ │ port 3001 │
│ ├──────────────────┤ │
│ │ PostgreSQL 16 │ │
│ │ (internal only) │ │
└──────────┴──────────────────┴────────────────────┘
```
## Stack
- Python 3.12, FastAPI, SQLAlchemy (async)
- React 18, Vite
- PostgreSQL 16
- FastMCP (Streamable HTTP/SSE)
- Docker Compose
## Updating
```bash
git pull
docker compose build
docker compose up -d
```
Database migrations run automatically on startup. Your data is preserved.
## Backing Up
Your data lives in the `fitness_db_data` Docker volume:
```bash
docker compose exec fitness-db pg_dump -U fitness fitness_rewards > backup.sql
```
To restore:
```bash
docker compose exec -i fitness-db psql -U fitness fitness_rewards < backup.sql
```
## Contributing
Contributions welcome. Please open an issue to discuss before submitting a PR.
---
## License
MIT — see [LICENSE](LICENSE).
MIT. See `LICENSE`.
Built by [DiligenceWorks Pte. Ltd.](https://diligenceworks.online)

240
TESTER-GUIDE.md Normal file
View file

@ -0,0 +1,240 @@
# Diligence — Beta Tester Guide
Thanks for helping us test Diligence! This guide walks you through setup, what to test, and how to give us useful feedback. No technical experience needed.
---
## What is Diligence?
Diligence is a fitness app that runs on your own computer. You earn points for workouts, food logging, and healthy habits, then spend them on rewards you choose. Everything stays on your machine — no cloud accounts, no data collection, no subscriptions.
---
## Setting Up (10 minutes)
### Step 1 — Install Python
Diligence needs Python (version 3.11 or newer) installed on your computer. If you're not sure whether you have it, open a terminal and type:
```
python --version
```
If it says `Python 3.11` or higher, you're good — skip to Step 2.
**If you need to install Python:**
- **Windows:** Go to https://www.python.org/downloads/ and click the big yellow "Download" button. During installation, **check the box that says "Add Python to PATH"** — this is important.
- **Mac:** Go to https://www.python.org/downloads/ and download the macOS installer. Run it and follow the prompts.
After installing, close and reopen your terminal, then check again with `python --version`.
> **Note for Mac users:** You may need to type `python3` instead of `python` throughout this guide.
### Step 2 — Download Diligence
Open a terminal (on Windows, search for "Command Prompt" or "PowerShell"; on Mac, search for "Terminal") and run these commands one at a time:
```
git clone https://github.com/DiligenceWorks/Diligence.git
cd Diligence
pip install .
```
> **Don't have git?** You can also download Diligence as a ZIP file from https://github.com/DiligenceWorks/Diligence — click the green "Code" button, then "Download ZIP." Unzip it, open a terminal in that folder, and run `pip install .`
### Step 3 — Run It
```
diligence
```
Your browser will open to `http://localhost:8000`. Create an account — pick any username and password you like. That's it, you're in.
> **If `diligence` doesn't work,** try `python -m diligence` instead.
### Stopping the App
Go back to your terminal and press `Ctrl+C`. Your data is saved automatically — next time you run `diligence`, everything will be right where you left it.
---
## What to Test
We need you to actually use the app as if it were your real fitness tracker. Below are the areas we'd love you to explore. You don't have to test everything — focus on whatever interests you, and spend at least a few days with it so you get a feel for the daily experience.
### 1. First-Time Setup (Onboarding)
When you first create an account, the app walks you through a health screening and motivation assessment. Pay attention to:
- Were the questions clear and easy to understand?
- Did any question feel confusing, irrelevant, or uncomfortable?
- Did the process feel too long, too short, or about right?
- At the end, did the app's summary of your fitness profile feel accurate?
### 2. Logging Workouts
Try logging different types of exercise — a walk, a gym session, a bike ride, yoga, whatever you actually do. Notice:
- Was it easy to find and log your activity?
- Did the points awarded feel fair for the effort?
- Did anything feel clunky or take too many clicks?
- Try logging something unusual — did the app handle it?
### 3. Food Logging
Log what you eat for a few days. The app searches a database of 400,000+ foods. Pay attention to:
- Could you find the foods you actually eat?
- Were the portion sizes and nutritional info reasonable?
- Was logging a meal quick enough that you'd actually do it daily?
- Try searching for a brand name, a generic food, and a homemade dish — how did each go?
### 4. Points and Rewards
The app has a points system with daily gates (minimums) and a reward shop. Test:
- Do you understand how points are earned?
- Does the daily gate make sense — is the minimum too easy, too hard?
- Try setting up a reward and redeeming it. Was the process clear?
- Does the points balance update correctly after activities and redemptions?
### 5. Meal Plans
If you use the meal planning feature, notice:
- Were the suggested meals realistic for your diet?
- Could you mark meals as followed or skipped easily?
- Did the compliance tracking (how well you stuck to the plan) feel accurate?
### 6. Programs (90-Day Training)
If you enroll in a training program:
- Were the scheduled workouts appropriate for your level?
- Was it clear what you were supposed to do each day?
- Did progress tracking work as expected?
### 7. General App Feel
Beyond specific features, we want to know:
- Is the app visually appealing and easy to navigate?
- Does it load quickly?
- Are there any pages that feel empty, broken, or confusing?
- Would you actually use this app day-to-day? Why or why not?
- Is there anything you expected to find but couldn't?
---
## How to Give Feedback
Good feedback is specific. Here's what helps us most:
### The Format
For each thing you notice, tell us:
1. **What you were doing** — "I was trying to log a 30-minute walk"
2. **What happened** — "The app showed 0 points even though I completed the log"
3. **What you expected** — "I expected to earn points for the activity"
4. **How it made you feel** — "Frustrating — felt like the effort wasn't recognized"
### Examples of Helpful Feedback
> "I searched for 'pad thai' in food logging and got zero results. I expected at least a generic entry. I ended up not logging my dinner because I couldn't find anything close."
> "The onboarding questions about motivation were great — I've never had a fitness app ask me WHY I exercise. The summary at the end nailed my personality. Made me want to keep using it."
> "I logged a workout and a meal on the same day but the points display only showed the workout points. After refreshing the page, both showed up. Minor but confusing."
> "The reward shop is a cool idea but I didn't understand what to put there. Maybe some example rewards would help new users get started?"
### What We Especially Want to Hear
- **Things that don't work** — errors, blank screens, buttons that do nothing
- **Things that are confusing** — where you had to guess or weren't sure what to do
- **Things that are missing** — features you looked for but couldn't find
- **Things you loved** — so we know what to keep and build on
- **Honest reactions** — if something feels pointless or annoying, say so
### How to Send It
Send your feedback to **hello@diligenceworks.online** with the subject line **"Diligence Beta Feedback"**. You can write it however you like — bullet points, paragraphs, voice-to-text, whatever works for you. Screenshots are very welcome if you can take them (on most computers: Windows key + Shift + S on Windows, Cmd + Shift + 4 on Mac).
---
## Connecting Claude to Diligence (Optional but Recommended)
You can connect Claude Desktop to Diligence so Claude can log your workouts, track your food, manage meal plans, and check your progress through conversation — just tell Claude what you did and it handles the rest.
### What You Need
- **Claude Desktop** (free) — download from https://claude.ai/download
- A Claude account (free tier works)
- Diligence running on your computer
### Setup Steps
1. Make sure Diligence is running (`python -m diligence` in your terminal)
2. Open your browser to `http://localhost:8000/agent`
3. You'll see a **Claude Desktop** section with a JSON config block — click **Copy**
4. Find your Claude Desktop config file:
- **Windows:** Press `Win + R`, paste `%APPDATA%\Claude\claude_desktop_config.json`, press Enter
- **Mac:** Open Finder, press `Cmd + Shift + G`, paste `~/Library/Application Support/Claude/claude_desktop_config.json`
5. Open that file in Notepad (Windows) or TextEdit (Mac)
6. Replace everything in the file with the JSON you copied. If the file doesn't exist, create it.
7. Save the file and **restart Claude Desktop**
### How to Use It
Once connected, you can talk to Claude naturally:
- *"I went for a 30-minute walk this morning"* — Claude logs the activity and tells you the points earned
- *"I had scrambled eggs and avocado for breakfast"* — Claude searches the food database and logs it with full nutrition info
- *"How am I doing today?"* — Claude shows your points, daily gate status, and what's left to earn
- *"What's my meal plan for today?"* — Claude pulls up your planned meals
### Important
- Diligence must be running in your terminal for Claude to connect. If you close the terminal, Claude loses the connection until you start Diligence again.
- You don't need Claude Desktop to use the app — the web interface works on its own. Claude is an optional power-up.
---
## Common Questions
**Where is my data stored?**
In a folder called `.diligence` in your home directory. On Windows that's `C:\Users\YourName\.diligence\`, on Mac it's `/Users/YourName/.diligence/`. It's a single file called `data.db`.
**Can I use it on my phone?**
Not as a native app, but if you run it on your computer you can open `http://your-computer-ip:8000` from your phone's browser while on the same Wi-Fi network. Run `diligence --host 0.0.0.0` to allow connections from other devices.
**Will I lose my data if I close the terminal?**
No. Your data is saved to disk continuously. Closing the terminal stops the app, but next time you run `diligence` everything is still there.
**Something broke and I want to start fresh.**
Delete the `.diligence` folder in your home directory and run `diligence` again. You'll go through onboarding as a new user.
**I got an error when installing.**
Make sure your Python version is 3.11 or newer (`python --version`). If you see "pip not found," try `python -m pip install .` instead of `pip install .`
---
## Uninstalling
If you want to remove Diligence completely:
```
pip uninstall diligence
```
Then delete the `.diligence` folder from your home directory if you want to remove your data too.
To remove the downloaded source code, just delete the `Diligence` folder wherever you cloned or unzipped it.
---
Thank you for testing. Every piece of feedback makes the app better for everyone.
*— The Diligence Team at DiligenceWorks Pte. Ltd.*

View file

@ -1,7 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
COPY diligence/ ./diligence/
CMD ["uvicorn", "diligence.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,45 +0,0 @@
from __future__ import annotations
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database - hostname 'fitness-db' avoids collision with other 'db' containers on Coolify network
database_url: str = "postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards"
# Auth
secret_key: str = "change-me-in-production"
crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
access_token_expire_minutes: int = 1440 # 24 hours
api_token: str = "" # MCP connector auth — generated by setup.sh
# Strava
strava_client_id: str = ""
strava_client_secret: str = ""
# Polar
polar_client_id: str = ""
polar_client_secret: str = ""
# Groq (program extraction)
groq_api_key: str = ""
# Telegram (support notifications — outbound only)
telegram_bot_token: str = ""
telegram_chat_id: str = ""
# App
base_url: str = "http://localhost"
timezone: str = "Asia/Bangkok"
model_config = {"env_file": ".env", "extra": "ignore"}
@lru_cache
def get_settings() -> Settings:
return Settings()
# Module-level shortcut used by services
settings = get_settings()

View file

@ -1,428 +0,0 @@
from __future__ import annotations
import asyncio
import sys
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("fitness-rewards")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Retry DB init — container may start before postgres is fully accepting connections
for attempt in range(10):
try:
from app.database import init_db
await init_db()
logger.info("Database tables created successfully")
break
except Exception as e:
logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}")
if attempt < 9:
await asyncio.sleep(3)
else:
logger.error("Could not initialize database after 10 attempts — starting without tables")
# SEC-05: Fail fast if SECRET_KEY not configured
from app.config import get_settings
_s = get_settings()
if _s.secret_key == "change-me-in-production":
logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
sys.exit(1)
# Run lightweight migrations for schema changes
try:
await run_migrations()
logger.info("Migrations completed")
except Exception as e:
logger.warning(f"Migration failed (non-fatal): {e}")
# Seed resources (non-fatal if it fails)
try:
await seed_resources()
logger.info("Resource library seeded")
except Exception as e:
logger.warning(f"Resource seeding failed (non-fatal): {e}")
# Start background crawl queue scheduler (gated on CRAWL_ENABLED)
crawl_task = None
if _s.crawl_enabled:
try:
from app.services.crawl_scheduler import crawl_queue_loop
crawl_task = asyncio.create_task(crawl_queue_loop())
logger.info("Crawl queue scheduler started")
except Exception as e:
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
else:
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
logger.info("Fitness Rewards backend started")
yield
# Shutdown
if crawl_task:
crawl_task.cancel()
try:
await crawl_task
except asyncio.CancelledError:
pass
logger.info("Fitness Rewards backend shutting down")
app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False, # Bearer tokens don't need credentials mode
allow_methods=["*"],
allow_headers=["*"],
)
# Import routers after app creation to avoid circular imports
from app.routers.auth import router as auth_router
from app.routers.onboarding import router as onboarding_router
from app.routers.activities import router as activities_router
from app.routers.food import router as food_router
from app.routers.points import router as points_router, rewards_router
from app.routers.integrations import router as integrations_router
from app.routers.programs import router as programs_router
from app.routers.catalog import router as catalog_router
from app.routers.support import router as support_router
from app.routers.nutrition import router as nutrition_router
from app.routers.meal_plans import router as meal_plans_router
from app.routers.ai_chat import router as ai_chat_router
app.include_router(auth_router)
app.include_router(onboarding_router)
app.include_router(activities_router)
app.include_router(food_router)
app.include_router(points_router)
app.include_router(rewards_router)
app.include_router(integrations_router)
app.include_router(catalog_router)
app.include_router(support_router)
app.include_router(programs_router)
app.include_router(nutrition_router)
app.include_router(meal_plans_router)
app.include_router(ai_chat_router)
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "1.0.0"}
async def run_migrations():
"""Add missing columns to existing tables (lightweight schema migration)."""
from app.database import engine
from sqlalchemy import text
async with engine.begin() as conn:
# v1: Add equipment_list JSONB column if missing
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS equipment_list JSONB DEFAULT '[]';
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v2: Add catalog columns to programs table
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE programs ADD COLUMN IF NOT EXISTS catalog_id UUID REFERENCES program_catalog(id);
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_week INTEGER DEFAULT 1;
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_day INTEGER DEFAULT 1;
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v2.1: Add week_number to workout_logs for template rotation tracking
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE workout_logs ADD COLUMN IF NOT EXISTS week_number INTEGER NOT NULL DEFAULT 1;
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v3: Seed keto point rules (fast_completed, keto_day, meal_logged)
# NOTE: Keto rules below are from v2. v3+ migrations (is_admin, integration_configs,
# meal plans) are added after this block.
await conn.execute(text("""
DO $$ BEGIN
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'fast_completed', 200, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'fast_completed'
);
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'keto_compliant_day', 100, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'keto_compliant_day'
);
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v4: Add is_admin column to users
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v4.1: Grant admin to first registered user if no admin exists
await conn.execute(text("""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE is_admin = TRUE) THEN
UPDATE users SET is_admin = TRUE
WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1);
END IF;
EXCEPTION WHEN undefined_table THEN NULL;
WHEN undefined_column THEN NULL;
END $$;
"""))
# v5: Create integration_configs table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS integration_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
provider VARCHAR(50) NOT NULL,
config_key VARCHAR(100) NOT NULL,
config_value TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, provider, config_key)
);
"""))
# v6: Create meal plan tables
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS meal_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
name VARCHAR(200) NOT NULL,
diet_type VARCHAR(50),
daily_calories INTEGER,
daily_protein_g INTEGER,
daily_carbs_g INTEGER,
daily_fat_g INTEGER,
restrictions JSONB DEFAULT '[]',
duration_days INTEGER NOT NULL,
start_date DATE NOT NULL,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW()
);
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS meal_plan_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
meal_type VARCHAR(20) NOT NULL,
food_name VARCHAR(300) NOT NULL,
description TEXT,
calories INTEGER,
protein_g DECIMAL(6,1),
carbs_g DECIMAL(6,1),
fat_g DECIMAL(6,1),
fiber_g DECIMAL(6,1),
serving_size VARCHAR(100),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS meal_compliance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
plan_id UUID NOT NULL REFERENCES meal_plans(id),
plan_item_id UUID,
compliance_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
substitution TEXT,
food_log_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_meal_plan_items_day ON meal_plan_items(plan_id, day_number);
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_meal_compliance_date ON meal_compliance(user_id, compliance_date);
"""))
# v7: Seed meal plan point rules
await conn.execute(text("""
DO $$ BEGIN
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'meal_plan_followed', 40, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_followed'
);
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'meal_plan_partial', 20, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_partial'
);
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
async def seed_resources():
"""Seed the resource library with curated fitness programs."""
from app.database import async_session
from app.models.resource import Resource
from sqlalchemy import select
async with async_session() as db:
existing = await db.execute(select(Resource).limit(1))
if existing.scalar_one_or_none():
return # Already seeded
resources = [
Resource(
name="Darebee Foundation Program",
source="darebee",
url="https://darebee.com/programs/foundation-program.html",
description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.",
goal_tags=["get_active", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee 30 Days of Change",
source="darebee",
url="https://darebee.com/programs/30-days-of-change.html",
description="30-day progressive program for building fitness habits. Bodyweight only.",
goal_tags=["get_active", "lose_weight", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee IRONHEART",
source="darebee",
url="https://darebee.com/programs/ironheart.html",
description="Strength-focused bodyweight program. No equipment, all levels.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee Total Body Strength",
source="darebee",
url="https://darebee.com/programs/total-body-strength.html",
description="Comprehensive strength building program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee 8 Weeks to 5K",
source="darebee",
url="https://darebee.com/programs/8-weeks-to-5k-program.html",
description="Progressive running program from beginner to 5K in 8 weeks.",
goal_tags=["get_active", "lose_weight"],
activity_tags=["running"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=56,
),
Resource(
name="StrongLifts 5x5",
source="stronglifts",
url="https://stronglifts.com/5x5/",
description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.",
goal_tags=["build_strength"],
activity_tags=["weights"],
equipment_needed="full_gym",
ttm_stages=["preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=90,
),
Resource(
name="Darebee 30 Days of Cardio",
source="darebee",
url="https://darebee.com/programs/30-days-of-cardio.html",
description="30-day cardio program, no equipment. Great for weight loss.",
goal_tags=["lose_weight", "get_active"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee POWERBUILDER",
source="darebee",
url="https://darebee.com/programs/powerbuilder-program.html",
description="Advanced strength and power program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="advanced",
duration_days=30,
),
Resource(
name="Fitness Blender (YouTube)",
source="youtube",
url="https://www.youtube.com/@fitnessblender",
description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.",
goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"],
activity_tags=["bodyweight", "yoga"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=None,
),
Resource(
name="Darebee Yoga Flexibility Program",
source="darebee",
url="https://darebee.com/programs/flexibility-program.html",
description="30-day flexibility and yoga program for beginners.",
goal_tags=["feel_better"],
activity_tags=["yoga"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
]
for r in resources:
db.add(r)
await db.commit()

View file

@ -1,24 +0,0 @@
from app.models.user import User
from app.models.profile import UserProfile
from app.models.program import Program
from app.models.activity import ActivityLog
from app.models.food import FoodLog
from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward, RewardRedemption
from app.models.oauth import OAuthToken
from app.models.resource import Resource
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
from app.models.support import SupportThread, SupportMessage
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
from app.models.integration_config import IntegrationConfig
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
__all__ = [
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
"OAuthToken", "Resource",
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
"SupportThread", "SupportMessage",
"NutritionGoal", "Fast", "ElectrolyteLog",
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
]

View file

@ -1,20 +0,0 @@
from __future__ import annotations
from datetime import date, timedelta
def get_week_boundaries(d: date) -> tuple[date, date]:
"""Return Monday and Sunday of the week containing date d."""
monday = d - timedelta(days=d.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
def get_month_boundaries(year: int, month: int) -> tuple[date, date]:
"""Return first and last day of a month."""
first = date(year, month, 1)
if month == 12:
last = date(year + 1, 1, 1) - timedelta(days=1)
else:
last = date(year, month + 1, 1) - timedelta(days=1)
return first, last

View file

@ -1,12 +1,14 @@
# Backend
# Core
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
httpx==0.28.1
python-multipart==0.0.18
# Database drivers
asyncpg==0.30.0
aiosqlite==0.20.0

3
diligence/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""Diligence — self-hosted fitness rewards platform with AI agent integration."""
__version__ = "2.0.0"

4
diligence/__main__.py Normal file
View file

@ -0,0 +1,4 @@
"""Allow running as: python -m diligence"""
from diligence.cli import main
main()

122
diligence/cli.py Normal file
View file

@ -0,0 +1,122 @@
"""Diligence CLI — run the fitness app from the command line."""
from __future__ import annotations
import argparse
import os
import sys
import secrets
import webbrowser
from pathlib import Path
def main():
parser = argparse.ArgumentParser(
prog="diligence",
description="Diligence — self-hosted fitness rewards platform",
)
parser.add_argument(
"--port", type=int, default=8000,
help="Port to run on (default: 8000)",
)
parser.add_argument(
"--host", type=str, default="127.0.0.1",
help="Host to bind to (default: 127.0.0.1)",
)
parser.add_argument(
"--no-browser", action="store_true",
help="Don't open the browser automatically",
)
parser.add_argument(
"--no-mcp", action="store_true",
help="Disable the MCP connector",
)
parser.add_argument(
"--mcp-port", type=int, default=3001,
help="Port for MCP SSE server (default: 3001)",
)
parser.add_argument(
"--data-dir", type=str, default=None,
help="Data directory (default: ~/.diligence)",
)
args = parser.parse_args()
# Set up data directory
data_dir = Path(args.data_dir) if args.data_dir else Path.home() / ".diligence"
data_dir.mkdir(parents=True, exist_ok=True)
# Auto-generate secrets on first run
env_file = data_dir / ".env"
api_token = ""
if not env_file.exists():
secret_key = secrets.token_hex(32)
api_token = secrets.token_hex(32)
env_file.write_text(
f"SECRET_KEY={secret_key}\n"
f"API_TOKEN={api_token}\n"
f"BASE_URL=http://localhost:{args.port}\n"
f"DATA_DIR={data_dir}\n"
)
print(f"Created config at {env_file}")
# Point pydantic-settings at the env file
os.environ.setdefault("DATA_DIR", str(data_dir))
# Load existing env file
if env_file.exists():
for line in env_file.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
if key.strip() == "API_TOKEN":
api_token = value.strip()
if args.no_mcp:
os.environ["MCP_ENABLED"] = "false"
# Start MCP server in background
mcp_started = False
if not args.no_mcp:
try:
from diligence.mcp import start_mcp_background
mcp_started = start_mcp_background(
api_url=f"http://localhost:{args.port}",
api_token=api_token,
port=args.mcp_port,
)
except ImportError:
pass
# Open browser after a short delay
url = f"http://localhost:{args.port}"
if not args.no_browser:
import threading
def _open():
import time
time.sleep(2)
webbrowser.open(url)
threading.Thread(target=_open, daemon=True).start()
print(f"\n Diligence running at {url}")
print(f" Data: {data_dir}")
if mcp_started:
print(f" MCP: http://localhost:{args.mcp_port}/sse")
elif not args.no_mcp:
print(f" MCP: not available (install with: pip install diligence[mcp])")
print(f" Press Ctrl+C to stop\n")
# Run uvicorn
import uvicorn
uvicorn.run(
"diligence.main:app",
host=args.host,
port=args.port,
log_level="info",
)
if __name__ == "__main__":
main()

71
diligence/config.py Normal file
View file

@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings
from functools import lru_cache
def _default_data_dir() -> Path:
"""~/.diligence on all platforms."""
d = Path.home() / ".diligence"
d.mkdir(exist_ok=True)
return d
class Settings(BaseSettings):
# Database — empty string triggers SQLite auto-detect (pip install path)
# Set DATABASE_URL explicitly for PostgreSQL (Docker path)
database_url: str = ""
# Auth
secret_key: str = "change-me-in-production"
crawl_enabled: bool = False
access_token_expire_minutes: int = 1440 # 24 hours
api_token: str = "" # MCP connector auth
# Strava
strava_client_id: str = ""
strava_client_secret: str = ""
# Polar
polar_client_id: str = ""
polar_client_secret: str = ""
# Groq (program extraction)
groq_api_key: str = ""
# Telegram (support notifications)
telegram_bot_token: str = ""
telegram_chat_id: str = ""
# App
base_url: str = "http://localhost:8000"
timezone: str = "UTC"
data_dir: str = str(_default_data_dir())
# MCP
mcp_enabled: bool = True
mcp_port: int = 3001
model_config = {"env_file": ".env", "extra": "ignore"}
@property
def effective_database_url(self) -> str:
"""Return the database URL, falling back to SQLite if not set."""
if self.database_url:
return self.database_url
db_path = Path(self.data_dir) / "data.db"
return f"sqlite+aiosqlite:///{db_path}"
@property
def is_sqlite(self) -> bool:
return self.effective_database_url.startswith("sqlite")
@lru_cache
def get_settings() -> Settings:
return Settings()
# Module-level shortcut used by services
settings = get_settings()

View file

@ -2,11 +2,17 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
from diligence.config import get_settings
settings = get_settings()
engine = create_async_engine(settings.database_url, echo=False, pool_size=5, max_overflow=10)
# Build engine with dialect-appropriate settings
_engine_kwargs: dict = {"echo": False}
if not settings.is_sqlite:
# PostgreSQL — connection pooling
_engine_kwargs.update(pool_size=5, max_overflow=10)
engine = create_async_engine(settings.effective_database_url, **_engine_kwargs)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@ -28,7 +34,7 @@ async def get_db():
async def init_db():
# Import all models so their tables are registered on Base.metadata
import app.models # noqa: F401
import diligence.models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View 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"
]
}
]
}

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>Diligence</title>
<script type="module" crossorigin src="/assets/index-CsKoBN0D.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ciTnvXdZ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -0,0 +1,31 @@
# Diligence — Self-Hosted Fitness Rewards Platform
> Data sovereignty for people. Self-hosted fitness tracker with a points-based
> behavioral economy and an MCP connector for AI agent integration.
> MIT-licensed. Deploy with a single `docker compose up -d`.
Diligence is a self-hosted fitness web app built around a points-based reward
system. Complete fitness activities and food logging to earn points, spend
points to unlock configurable real-world rewards. Science-based onboarding
(PAR-Q+, TTM stages of change, BREQ-2 motivation assessment).
## For AI Agents
- MCP Server: /mcp (Streamable HTTP/SSE, 14 tools)
- Agent Card: /.well-known/agent-card.json
- Skills: /.well-known/skills/diligence-fitness/SKILL.md
## MCP Tools
- get_context, get_today, get_week (status)
- log_activity, log_food, search_food (tracking)
- get_program_schedule (programs)
- redeem_reward (rewards)
- load_meal_plan, get_meal_plan, update_meal_compliance, get_plan_progress (meal plans)
- configure_integration, get_integration_status (device sync)
## Technical
- Source: https://github.com/diligenceworks/diligence (MIT)
- Stack: FastAPI + PostgreSQL + React/Vite + FastMCP
## About
Built by DiligenceWorks (https://diligenceworks.online) — adversarial AI
deal-diligence platform for institutional investors.

402
diligence/main.py Normal file
View file

@ -0,0 +1,402 @@
from __future__ import annotations
import asyncio
import sys
import logging
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("diligence")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Retry DB init — container may start before postgres is ready
for attempt in range(10):
try:
from diligence.database import init_db
await init_db()
logger.info("Database tables created successfully")
break
except Exception as e:
logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}")
if attempt < 9:
await asyncio.sleep(3)
else:
logger.error("Could not initialize database after 10 attempts")
# Fail fast if SECRET_KEY not configured
from diligence.config import get_settings
_s = get_settings()
if _s.secret_key == "change-me-in-production":
logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
sys.exit(1)
# Run lightweight migrations for schema changes
try:
await run_migrations()
logger.info("Migrations completed")
except Exception as e:
logger.warning(f"Migration failed (non-fatal): {e}")
# Seed resources
try:
await seed_resources()
logger.info("Resource library seeded")
except Exception as e:
logger.warning(f"Resource seeding failed (non-fatal): {e}")
# Start background crawl queue scheduler
crawl_task = None
if _s.crawl_enabled:
try:
from diligence.services.crawl_scheduler import crawl_queue_loop
crawl_task = asyncio.create_task(crawl_queue_loop())
logger.info("Crawl queue scheduler started")
except Exception as e:
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
else:
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
logger.info("Diligence backend started")
yield
if crawl_task:
crawl_task.cancel()
try:
await crawl_task
except asyncio.CancelledError:
pass
logger.info("Diligence backend shutting down")
app = FastAPI(title="Diligence", version="2.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# Import routers after app creation to avoid circular imports
from diligence.routers.auth import router as auth_router
from diligence.routers.onboarding import router as onboarding_router
from diligence.routers.activities import router as activities_router
from diligence.routers.food import router as food_router
from diligence.routers.points import router as points_router, rewards_router
from diligence.routers.integrations import router as integrations_router
from diligence.routers.programs import router as programs_router
from diligence.routers.catalog import router as catalog_router
from diligence.routers.support import router as support_router
from diligence.routers.nutrition import router as nutrition_router
from diligence.routers.meal_plans import router as meal_plans_router
from diligence.routers.ai_chat import router as ai_chat_router
from diligence.routers.agent import router as agent_router
app.include_router(auth_router)
app.include_router(onboarding_router)
app.include_router(activities_router)
app.include_router(food_router)
app.include_router(points_router)
app.include_router(rewards_router)
app.include_router(integrations_router)
app.include_router(catalog_router)
app.include_router(support_router)
app.include_router(programs_router)
app.include_router(nutrition_router)
app.include_router(meal_plans_router)
app.include_router(ai_chat_router)
app.include_router(agent_router)
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "2.0.0"}
# --- Static frontend serving (pip install path) ---
# When running without nginx, serve the pre-built React app directly.
# The frontend/ directory is bundled with the pip package.
_frontend_dir = Path(__file__).parent / "frontend"
if _frontend_dir.is_dir() and (_frontend_dir / "index.html").exists():
# B2A discovery files
@app.get("/llms.txt")
async def llms_txt():
p = _frontend_dir / "llms.txt"
if p.exists():
return FileResponse(p, media_type="text/plain")
@app.get("/.well-known/{path:path}")
async def well_known(path: str):
p = _frontend_dir / ".well-known" / path
if p.exists():
media = "text/plain" if path.endswith(".md") else "application/json"
return FileResponse(p, media_type=media)
# Static assets (js, css, images)
app.mount("/assets", StaticFiles(directory=_frontend_dir / "assets"), name="assets")
# SPA catch-all — must be last
@app.get("/{path:path}")
async def spa_catchall(path: str):
# Don't intercept /api routes (already handled above)
if path.startswith("api/"):
return
file_path = _frontend_dir / path
if file_path.is_file():
return FileResponse(file_path)
return FileResponse(_frontend_dir / "index.html")
logger.info(f"Serving frontend from {_frontend_dir}")
# --- Cross-dialect migrations ---
async def run_migrations():
"""Add missing columns/tables. Works on both PostgreSQL and SQLite."""
from diligence.database import engine
from diligence.config import get_settings
from sqlalchemy import text, inspect as sa_inspect
settings = get_settings()
async with engine.begin() as conn:
# Get table inspector
def _get_tables_and_columns(connection):
inspector = sa_inspect(connection)
tables = inspector.get_table_names()
columns = {}
for t in tables:
columns[t] = [c["name"] for c in inspector.get_columns(t)]
return tables, columns
tables, columns = await conn.run_sync(_get_tables_and_columns)
# v1: Add equipment_list column if missing
if "user_profiles" in tables and "equipment_list" not in columns.get("user_profiles", []):
if settings.is_sqlite:
await conn.execute(text(
"ALTER TABLE user_profiles ADD COLUMN equipment_list TEXT DEFAULT '[]'"
))
else:
await conn.execute(text(
"ALTER TABLE user_profiles ADD COLUMN equipment_list JSONB DEFAULT '[]'"
))
# v2: Add catalog columns to programs table
if "programs" in tables:
prog_cols = columns.get("programs", [])
if "catalog_id" not in prog_cols:
await conn.execute(text(
"ALTER TABLE programs ADD COLUMN catalog_id VARCHAR(36)"
))
if "current_week" not in prog_cols:
await conn.execute(text(
"ALTER TABLE programs ADD COLUMN current_week INTEGER DEFAULT 1"
))
if "current_day" not in prog_cols:
await conn.execute(text(
"ALTER TABLE programs ADD COLUMN current_day INTEGER DEFAULT 1"
))
# v2.1: Add week_number to workout_logs
if "workout_logs" in tables and "week_number" not in columns.get("workout_logs", []):
await conn.execute(text(
"ALTER TABLE workout_logs ADD COLUMN week_number INTEGER DEFAULT 1"
))
# v4: Add is_admin to users
if "users" in tables and "is_admin" not in columns.get("users", []):
await conn.execute(text(
"ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE"
))
# v4.1: Grant admin to first registered user if none exists
if "users" in tables and "is_admin" in columns.get("users", []):
result = await conn.execute(text(
"SELECT COUNT(*) FROM users WHERE is_admin = TRUE"
))
admin_count = result.scalar()
if admin_count == 0:
await conn.execute(text(
"UPDATE users SET is_admin = TRUE "
"WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1)"
))
# v5: integration_configs table — handled by create_all
# v6: meal plan tables — handled by create_all
# v7: point rules seeding
if "point_rules" in tables and "users" in tables:
for category, points in [
("fast_completed", 200),
("keto_compliant_day", 100),
("meal_plan_followed", 40),
("meal_plan_partial", 20),
]:
# Check if any user is missing this rule
result = await conn.execute(text(
"SELECT u.id FROM users u "
"WHERE NOT EXISTS ("
" SELECT 1 FROM point_rules pr "
" WHERE pr.user_id = u.id AND pr.category = :cat"
")"
), {"cat": category})
missing_users = result.fetchall()
for (user_id,) in missing_users:
# Use Python uuid for SQLite compatibility
import uuid
await conn.execute(text(
"INSERT INTO point_rules (id, user_id, category, points, unit, is_active) "
"VALUES (:id, :uid, :cat, :pts, 'per_event', TRUE)"
), {
"id": str(uuid.uuid4()),
"uid": str(user_id),
"cat": category,
"pts": points,
})
async def seed_resources():
"""Seed the resource library with curated fitness programs."""
from diligence.database import async_session
from diligence.models.resource import Resource
from sqlalchemy import select
async with async_session() as db:
existing = await db.execute(select(Resource).limit(1))
if existing.scalar_one_or_none():
return
resources = [
Resource(
name="Darebee Foundation Program",
source="darebee",
url="https://darebee.com/programs/foundation-program.html",
description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.",
goal_tags=["get_active", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee 30 Days of Change",
source="darebee",
url="https://darebee.com/programs/30-days-of-change.html",
description="30-day progressive program for building fitness habits. Bodyweight only.",
goal_tags=["get_active", "lose_weight", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee IRONHEART",
source="darebee",
url="https://darebee.com/programs/ironheart.html",
description="Strength-focused bodyweight program. No equipment, all levels.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee Total Body Strength",
source="darebee",
url="https://darebee.com/programs/total-body-strength.html",
description="Comprehensive strength building program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee 8 Weeks to 5K",
source="darebee",
url="https://darebee.com/programs/8-weeks-to-5k-program.html",
description="Progressive running program from beginner to 5K in 8 weeks.",
goal_tags=["get_active", "lose_weight"],
activity_tags=["running"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=56,
),
Resource(
name="StrongLifts 5x5",
source="stronglifts",
url="https://stronglifts.com/5x5/",
description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.",
goal_tags=["build_strength"],
activity_tags=["weights"],
equipment_needed="full_gym",
ttm_stages=["preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=90,
),
Resource(
name="Darebee 30 Days of Cardio",
source="darebee",
url="https://darebee.com/programs/30-days-of-cardio.html",
description="30-day cardio program, no equipment. Great for weight loss.",
goal_tags=["lose_weight", "get_active"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee POWERBUILDER",
source="darebee",
url="https://darebee.com/programs/powerbuilder-program.html",
description="Advanced strength and power program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="advanced",
duration_days=30,
),
Resource(
name="Fitness Blender (YouTube)",
source="youtube",
url="https://www.youtube.com/@fitnessblender",
description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.",
goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"],
activity_tags=["bodyweight", "yoga"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=None,
),
Resource(
name="Darebee Yoga Flexibility Program",
source="darebee",
url="https://darebee.com/programs/flexibility-program.html",
description="30-day flexibility and yoga program for beginners.",
goal_tags=["feel_better"],
activity_tags=["yoga"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
]
for r in resources:
db.add(r)
await db.commit()

41
diligence/mcp/__init__.py Normal file
View file

@ -0,0 +1,41 @@
"""MCP server startup for the pip install path.
Starts FastMCP in a background thread so AI agents can connect
to the same machine running Diligence. Requires: pip install diligence[mcp]
"""
from __future__ import annotations
import threading
import logging
logger = logging.getLogger("diligence.mcp")
def start_mcp_background(api_url: str, api_token: str = "", port: int = 3001):
"""Start the MCP SSE server in a background thread.
Args:
api_url: Backend API base URL (e.g. http://localhost:8000)
api_token: Bearer token for API auth
port: Port for the MCP SSE server (default 3001)
"""
try:
from diligence.mcp.server import create_mcp_server
except ImportError:
logger.warning("MCP package not installed. Install with: pip install diligence[mcp]")
return False
def _run():
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
mcp = create_mcp_server(api_url=api_url, api_token=api_token, port=port)
mcp.run(transport="sse")
except Exception as e:
logger.error(f"MCP server failed: {e}")
thread = threading.Thread(target=_run, daemon=True, name="mcp-server")
thread.start()
logger.info(f"MCP server starting on port {port}")
return True

223
diligence/mcp/server.py Normal file
View file

@ -0,0 +1,223 @@
"""Diligence MCP Server — 14 tools for AI agent integration.
For the pip install path, this runs in-process as a background thread.
For the Docker path, this runs as a separate container (mcp-connector/).
"""
from __future__ import annotations
import json
import httpx
from mcp.server.fastmcp import FastMCP
_client: httpx.AsyncClient | None = None
def create_mcp_server(api_url: str = "http://localhost:8000", api_token: str = "", port: int = 3001) -> FastMCP:
"""Create and configure the MCP server with all 14 tools."""
mcp = FastMCP("Diligence Fitness", port=port)
async def api(method: str, path: str, **kwargs) -> dict:
global _client
if _client is None:
headers = {}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
_client = httpx.AsyncClient(
base_url=api_url, timeout=30, headers=headers
)
resp = await getattr(_client, method)(f"/api{path}", **kwargs)
resp.raise_for_status()
return resp.json()
# --- CONTEXT & STATUS (3 tools) ---
@mcp.tool()
async def get_context() -> str:
"""Get full user profile, motivation type, program status, point rules, rewards, and integration status. Call this at the start of each conversation to understand the user's current state."""
data = await api("get", "/points/today")
profile = await api("get", "/onboarding/profile")
rewards = await api("get", "/points/rewards")
integrations = await api("get", "/integrations/status")
meal_plan = None
try:
meal_plan = await api("get", "/meal-plans/today")
except Exception:
pass
return json.dumps({
"user": profile, "today": data, "rewards": rewards,
"integrations": integrations, "active_meal_plan": meal_plan,
}, indent=2, default=str)
@mcp.tool()
async def get_today(date_str: str | None = None) -> str:
"""Get today's points earned, daily gate status, breakdown by category, and activities logged. Optionally pass a date (YYYY-MM-DD)."""
params = {"date": date_str} if date_str else {}
data = await api("get", "/points/today", params=params)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_week(start_date: str | None = None) -> str:
"""Get weekly summary including total points, active days count, target progress, and day-by-day breakdown."""
params = {"start_date": start_date} if start_date else {}
data = await api("get", "/points/week", params=params)
return json.dumps(data, indent=2, default=str)
# --- ACTIVITY LOGGING (1 tool) ---
@mcp.tool()
async def log_activity(
category: str, title: str, duration_minutes: int,
notes: str | None = None, date_str: str | None = None,
program_day: int | None = None,
) -> str:
"""Log a workout or activity. Returns points earned and daily total.
Args:
category: Type of activity (workout, cardio, yoga, walking, cycling, swimming, other)
title: Description of what was done
duration_minutes: How long the activity lasted
notes: Optional additional notes
date_str: Optional date (YYYY-MM-DD), defaults to today
program_day: Optional program day number
"""
payload = {"category": category, "title": title, "duration_minutes": duration_minutes}
if notes: payload["notes"] = notes
if date_str: payload["date"] = date_str
if program_day: payload["program_day"] = program_day
data = await api("post", "/activities/log", json=payload)
return json.dumps(data, indent=2, default=str)
# --- FOOD & NUTRITION (2 tools) ---
@mcp.tool()
async def log_food(
meal_type: str, food_name: str, calories: int | None = None,
protein_g: float | None = None, carbs_g: float | None = None,
fat_g: float | None = None, servings: float | None = None,
plan_item_id: str | None = None,
) -> str:
"""Log a food item with optional nutrition data.
Args:
meal_type: One of breakfast, lunch, dinner, snack
food_name: What was eaten
calories: Estimated calories
protein_g: Grams of protein
carbs_g: Grams of carbs
fat_g: Grams of fat
servings: Number of servings (default 1)
plan_item_id: Optional meal plan item ID for compliance
"""
payload = {"meal_type": meal_type, "food_name": food_name}
for k, v in [("calories", calories), ("protein_g", protein_g), ("carbs_g", carbs_g),
("fat_g", fat_g), ("servings", servings), ("plan_item_id", plan_item_id)]:
if v is not None: payload[k] = v
data = await api("post", "/food/log", json=payload)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def search_food(query: str) -> str:
"""Search for food items in Open Food Facts and USDA FoodData Central. Returns nutrition data for matching items."""
data = await api("get", "/food/search", params={"q": query})
return json.dumps(data, indent=2, default=str)
# --- PROGRAMS (1 tool) ---
@mcp.tool()
async def get_program_schedule(date_str: str | None = None) -> str:
"""Get today's scheduled workout from the active program, including exercises with sets, reps, and weight progression."""
params = {"date": date_str} if date_str else {}
data = await api("get", "/programs/schedule", params=params)
return json.dumps(data, indent=2, default=str)
# --- REWARDS (1 tool) ---
@mcp.tool()
async def redeem_reward(reward_name: str) -> str:
"""Spend points on a configured reward. Returns success/failure and remaining points."""
data = await api("post", "/points/rewards/redeem", json={"name": reward_name})
return json.dumps(data, indent=2, default=str)
# --- MEAL PLANS (4 tools) ---
@mcp.tool()
async def load_meal_plan(
name: str, duration_days: int, meals: list[dict],
diet_type: str | None = None, daily_calories: int | None = None,
restrictions: list[str] | None = None,
) -> str:
"""Create a complete meal plan with all items.
Args:
name: Plan name (e.g. "7-Day Dairy-Free Keto")
duration_days: How many days the plan covers
meals: List of meal items with day_number, meal_type, food_name, calories, protein_g, carbs_g, fat_g
diet_type: Optional diet type (keto, paleo, balanced, custom)
daily_calories: Optional daily calorie target
restrictions: Optional list of dietary restrictions
"""
payload = {"name": name, "duration_days": duration_days, "meals": meals}
if diet_type: payload["diet_type"] = diet_type
if daily_calories: payload["daily_calories"] = daily_calories
if restrictions: payload["restrictions"] = restrictions
data = await api("post", "/meal-plans", json=payload)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_meal_plan(date_str: str | None = None) -> str:
"""View the active meal plan's meals for today or a specified date."""
data = await api("get", "/meal-plans/today")
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def update_meal_compliance(
plan_item_id: str, status: str, substitution: str | None = None,
) -> str:
"""Mark a planned meal as followed, substituted, or skipped.
Args:
plan_item_id: The meal plan item ID
status: One of 'followed', 'substituted', 'skipped'
substitution: What was eaten instead (required if 'substituted')
"""
payload = {"plan_item_id": plan_item_id, "status": status}
if substitution: payload["substitution"] = substitution
data = await api("post", "/meal-plans/compliance", json=payload)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_plan_progress(plan_id: str | None = None) -> str:
"""Get overall compliance stats for a meal plan."""
if plan_id:
data = await api("get", f"/meal-plans/{plan_id}/progress")
else:
plans = await api("get", "/meal-plans")
active = next((p for p in plans if p.get("status") == "active"), None)
if not active:
return json.dumps({"error": "No active meal plan"})
data = await api("get", f"/meal-plans/{active['id']}/progress")
return json.dumps(data, indent=2, default=str)
# --- INTEGRATIONS (2 tools) ---
@mcp.tool()
async def configure_integration(provider: str, credentials: dict) -> str:
"""Store encrypted integration credentials. Write-only.
Args:
provider: Provider name (strava, polar, garmin, fitbit, usda, telegram, groq, etc.)
credentials: Dict of credential fields
"""
data = await api("post", "/integrations/configure", json={
"provider": provider, "credentials": credentials,
})
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_integration_status() -> str:
"""Check connection status of all integration providers."""
data = await api("get", "/integrations/status")
return json.dumps(data, indent=2, default=str)
return mcp

View file

@ -0,0 +1,24 @@
from diligence.models.user import User
from diligence.models.profile import UserProfile
from diligence.models.program import Program
from diligence.models.activity import ActivityLog
from diligence.models.food import FoodLog
from diligence.models.points import PointRule, DailyTarget
from diligence.models.reward import Reward, RewardRedemption
from diligence.models.oauth import OAuthToken
from diligence.models.resource import Resource
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
from diligence.models.support import SupportThread, SupportMessage
from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
from diligence.models.integration_config import IntegrationConfig
from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
__all__ = [
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
"OAuthToken", "Resource",
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
"SupportThread", "SupportMessage",
"NutritionGoal", "Fast", "ElectrolyteLog",
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
]

View file

@ -2,17 +2,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index, Uuid, JSON
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from diligence.database import Base
class ActivityLog(Base):
__tablename__ = "activity_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str | None] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(Text)
@ -20,12 +19,12 @@ class ActivityLog(Base):
source: Mapped[str] = mapped_column(String(30), default="manual")
external_id: Mapped[str | None] = mapped_column(String(100))
points_earned: Mapped[int] = mapped_column(Integer, default=0)
rule_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("point_rules.id"), nullable=True)
rule_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("point_rules.id"), nullable=True)
activity_date: Mapped[date] = mapped_column(Date, nullable=False)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
program_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("programs.id"), nullable=True)
program_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("programs.id"), nullable=True)
program_day: Mapped[int | None] = mapped_column(Integer)
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
metadata_json: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
__table_args__ = (
Index("idx_activity_log_user_date", "user_id", "activity_date"),

View file

@ -2,28 +2,27 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint, Uuid, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from diligence.database import Base
class ProgramCatalog(Base):
"""Shared program definitions — one per program, reusable by all users."""
__tablename__ = "program_catalog"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
source_url: Mapped[str | None] = mapped_column(Text)
duration_weeks: Mapped[int | None] = mapped_column(Integer)
frequency_per_week: Mapped[int | None] = mapped_column(Integer)
equipment: Mapped[dict] = mapped_column(JSONB, default=list)
equipment: Mapped[dict] = mapped_column(JSON, default=list)
difficulty: Mapped[str | None] = mapped_column(String(20))
category: Mapped[str | None] = mapped_column(String(50))
progression_rules: Mapped[str | None] = mapped_column(Text)
structured_data: Mapped[dict | None] = mapped_column(JSONB)
structured_data: Mapped[dict | None] = mapped_column(JSON)
crawl_status: Mapped[str] = mapped_column(String(20), default="pending")
crawl_error: Mapped[str | None] = mapped_column(Text)
crawled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@ -41,14 +40,14 @@ class CatalogWorkout(Base):
"""Individual workout within a catalog program."""
__tablename__ = "catalog_workouts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
catalog_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False
Uuid, ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False
)
week_number: Mapped[int] = mapped_column(Integer, nullable=False)
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
workout_name: Mapped[str | None] = mapped_column(String(200))
exercises: Mapped[dict] = mapped_column(JSONB, nullable=False)
exercises: Mapped[dict] = mapped_column(JSON, nullable=False)
notes: Mapped[str | None] = mapped_column(Text)
rest_day: Mapped[bool] = mapped_column(Boolean, default=False)
@ -63,14 +62,14 @@ class CrawlQueue(Base):
"""Job queue for program research crawls."""
__tablename__ = "crawl_queue"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
Uuid, ForeignKey("program_catalog.id"), nullable=True
)
search_query: Mapped[str] = mapped_column(String(500), nullable=False)
priority: Mapped[str] = mapped_column(String(10), default="low")
status: Mapped[str] = mapped_column(String(20), default="pending")
urls_to_crawl: Mapped[dict] = mapped_column(JSONB, default=list)
urls_to_crawl: Mapped[dict] = mapped_column(JSON, default=list)
crawled_content: Mapped[str | None] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
@ -84,20 +83,20 @@ class WorkoutLog(Base):
"""Per-user workout completion tracking against catalog workouts."""
__tablename__ = "workout_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
Uuid, ForeignKey("users.id"), nullable=False
)
program_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("programs.id"), nullable=False
Uuid, ForeignKey("programs.id"), nullable=False
)
catalog_workout_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("catalog_workouts.id"), nullable=False
Uuid, ForeignKey("catalog_workouts.id"), nullable=False
)
week_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
exercises_completed: Mapped[dict | None] = mapped_column(JSONB)
exercises_completed: Mapped[dict | None] = mapped_column(JSON)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text)

View file

@ -3,17 +3,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from decimal import Decimal
from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index
from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index, Uuid, JSON
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from diligence.database import Base
class FoodLog(Base):
__tablename__ = "food_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
brand: Mapped[str | None] = mapped_column(String(200))
@ -30,7 +29,7 @@ class FoodLog(Base):
food_date: Mapped[date] = mapped_column(Date, nullable=False)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
off_product_id: Mapped[str | None] = mapped_column(String(50))
off_data: Mapped[dict] = mapped_column(JSONB, default=dict)
off_data: Mapped[dict] = mapped_column(JSON, default=dict)
__table_args__ = (
Index("idx_food_log_user_date", "user_id", "food_date"),

View file

@ -3,17 +3,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Text, UniqueConstraint
from sqlalchemy import String, DateTime, Text, UniqueConstraint, Uuid
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from diligence.database import Base
class IntegrationConfig(Base):
__tablename__ = "integration_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
config_key: Mapped[str] = mapped_column(String(100), nullable=False)
config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted

View file

@ -4,24 +4,23 @@ from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from decimal import Decimal
from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL
from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL, Uuid, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from diligence.database import Base
class MealPlan(Base):
__tablename__ = "meal_plans"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
daily_calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
daily_protein_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
daily_carbs_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
daily_fat_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
restrictions: Mapped[dict] = mapped_column(JSONB, default=list)
restrictions: Mapped[dict] = mapped_column(JSON, default=list)
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="active")
@ -33,8 +32,8 @@ class MealPlan(Base):
class MealPlanItem(Base):
__tablename__ = "meal_plan_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
plan_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False)
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
@ -54,12 +53,12 @@ class MealPlanItem(Base):
class MealCompliance(Base):
__tablename__ = "meal_compliance"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id"), nullable=False)
plan_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
plan_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("meal_plans.id"), nullable=False)
plan_item_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
compliance_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped
substitution: Mapped[str | None] = mapped_column(Text, nullable=True)
food_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
food_log_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -3,18 +3,17 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index
from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index, Uuid
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from diligence.database import Base
class NutritionGoal(Base):
"""Per-user macro + eating-window targets. One active per user."""
__tablename__ = "nutrition_goals"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
# Macro targets (grams / kcal)
calorie_target: Mapped[int] = mapped_column(Integer, default=2400)
@ -33,7 +32,7 @@ class NutritionGoal(Base):
default_fast_hours: Mapped[int] = mapped_column(Integer, default=16) # 16:8
diet_style: Mapped[str] = mapped_column(String(30), default="strict_keto")
timezone_str: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
timezone_str: Mapped[str] = mapped_column(String(50), default="UTC")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
@ -44,8 +43,8 @@ class Fast(Base):
"""A single fasting bout — start/end + type + compliance flag."""
__tablename__ = "fasts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@ -60,7 +59,7 @@ class Fast(Base):
# Points granted (for idempotency — don't double-credit)
points_awarded: Mapped[int] = mapped_column(Integer, default=0)
activity_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("activity_log.id"))
activity_log_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("activity_log.id"))
notes: Mapped[str | None] = mapped_column(Text)
@ -75,8 +74,8 @@ class ElectrolyteLog(Base):
"""Daily electrolyte dosing checklist — lightweight tracker."""
__tablename__ = "electrolyte_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
log_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
sodium_mg: Mapped[int] = mapped_column(Integer, default=0)

View file

@ -2,17 +2,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint, Uuid
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from diligence.database import Base
class OAuthToken(Base):
__tablename__ = "oauth_tokens"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
provider: Mapped[str] = mapped_column(String(20), nullable=False)
access_token: Mapped[str] = mapped_column(Text, nullable=False)
refresh_token: Mapped[str | None] = mapped_column(Text)

View file

@ -2,17 +2,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from diligence.database import Base
class PointRule(Base):
__tablename__ = "point_rules"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str] = mapped_column(String(200), nullable=False)
points: Mapped[int] = mapped_column(Integer, nullable=False)
@ -26,8 +25,8 @@ class PointRule(Base):
class DailyTarget(Base):
__tablename__ = "daily_targets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
daily_minimum_pts: Mapped[int] = mapped_column(Integer, default=80)
weekly_target_pts: Mapped[int] = mapped_column(Integer, default=500)
weekly_bonus_pts: Mapped[int] = mapped_column(Integer, default=50)

View file

@ -3,17 +3,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey
from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey, Uuid, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from diligence.database import Base
class UserProfile(Base):
__tablename__ = "user_profiles"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
# Phase 1
primary_goal: Mapped[str | None] = mapped_column(String(50))
@ -41,9 +40,9 @@ class UserProfile(Base):
motivation_rai: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
# Preferences
activity_preferences: Mapped[dict] = mapped_column(JSONB, default=list)
activity_preferences: Mapped[dict] = mapped_column(JSON, default=list)
equipment_access: Mapped[str | None] = mapped_column(String(50)) # legacy — kept for compat
equipment_list: Mapped[dict] = mapped_column(JSONB, default=list) # new: list of equipment strings
equipment_list: Mapped[dict] = mapped_column(JSON, default=list) # new: list of equipment strings
days_per_week: Mapped[int] = mapped_column(Integer, default=3)
minutes_per_session: Mapped[int] = mapped_column(Integer, default=30)

View file

@ -2,17 +2,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from diligence.database import Base
class Program(Base):
__tablename__ = "programs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
source: Mapped[str | None] = mapped_column(String(50))
source_url: Mapped[str | None] = mapped_column(Text)
@ -24,7 +23,7 @@ class Program(Base):
# v2: Link to catalog program
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
Uuid, ForeignKey("program_catalog.id"), nullable=True
)
current_week: Mapped[int] = mapped_column(Integer, default=1)
current_day: Mapped[int] = mapped_column(Integer, default=1)

View file

@ -2,25 +2,24 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Boolean, Text, DateTime
from sqlalchemy import String, Integer, Boolean, Text, DateTime, Uuid, JSON
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from diligence.database import Base
class Resource(Base):
__tablename__ = "resource_library"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
source: Mapped[str] = mapped_column(String(50), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
thumbnail_url: Mapped[str | None] = mapped_column(Text)
goal_tags: Mapped[dict] = mapped_column(JSONB, default=list)
activity_tags: Mapped[dict] = mapped_column(JSONB, default=list)
goal_tags: Mapped[dict] = mapped_column(JSON, default=list)
activity_tags: Mapped[dict] = mapped_column(JSON, default=list)
equipment_needed: Mapped[str | None] = mapped_column(String(50))
ttm_stages: Mapped[dict] = mapped_column(JSONB, default=list)
ttm_stages: Mapped[dict] = mapped_column(JSON, default=list)
difficulty: Mapped[str | None] = mapped_column(String(20))
duration_days: Mapped[int | None] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View file

@ -2,17 +2,16 @@ from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index
from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index, Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from diligence.database import Base
class Reward(Base):
__tablename__ = "rewards"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
point_cost: Mapped[int] = mapped_column(Integer, nullable=False)
@ -25,9 +24,9 @@ class Reward(Base):
class RewardRedemption(Base):
__tablename__ = "reward_redemptions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
reward_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rewards.id"), nullable=False)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
reward_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("rewards.id"), nullable=False)
points_spent: Mapped[int] = mapped_column(Integer, nullable=False)
redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
redemption_date: Mapped[date] = mapped_column(Date, nullable=False)

View file

@ -2,19 +2,18 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint, Uuid, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from diligence.database import Base
class SupportThread(Base):
"""One thread per user — private conversation with admin."""
__tablename__ = "support_threads"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), unique=True, nullable=False
Uuid, ForeignKey("users.id"), unique=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
@ -35,13 +34,13 @@ class SupportMessage(Base):
"""Individual message within a support thread."""
__tablename__ = "support_messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
thread_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False
Uuid, ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False
)
sender: Mapped[str] = mapped_column(String(10), nullable=False) # 'user' or 'admin'
body: Mapped[str] = mapped_column(Text, nullable=False)
context: Mapped[dict | None] = mapped_column(JSONB)
context: Mapped[dict | None] = mapped_column(JSON)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)

View file

@ -2,23 +2,22 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy import String, Boolean, DateTime, Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from diligence.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships

View file

@ -6,12 +6,12 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.activity import ActivityLog
from app.schemas.activity import ActivityCreate, ActivityResponse
from app.utils.auth import get_current_user
from app.services.points_engine import log_activity_with_points
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.activity import ActivityLog
from diligence.schemas.activity import ActivityCreate, ActivityResponse
from diligence.utils.auth import get_current_user
from diligence.services.points_engine import log_activity_with_points
router = APIRouter(prefix="/api/activities", tags=["activities"])

View file

@ -0,0 +1,38 @@
"""Agent connection endpoint — returns MCP config for AI agent setup."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from diligence.config import get_settings
from diligence.utils.auth import get_current_user
router = APIRouter(prefix="/api/agent", tags=["agent"])
@router.get("/config")
async def agent_config(user=Depends(get_current_user)):
"""Return MCP connection details for the authenticated user."""
settings = get_settings()
base = settings.base_url.rstrip("/")
if settings.is_sqlite:
# pip install path — MCP runs on separate port (same machine)
from urllib.parse import urlparse
parsed = urlparse(base)
host = parsed.hostname or "localhost"
mcp_url = f"http://{host}:{settings.mcp_port}/sse"
else:
# Docker path — MCP on separate container port 3001
from urllib.parse import urlparse
parsed = urlparse(base)
docker_host = parsed.hostname or "localhost"
mcp_url = f"http://{docker_host}:3001/sse"
return {
"mcp_url": mcp_url,
"api_token": settings.api_token if getattr(user, "is_admin", False) else None,
"api_token_set": bool(settings.api_token),
"deployment": "local" if settings.is_sqlite else "docker",
"base_url": base,
"tools_count": 14,
}

View file

@ -7,10 +7,10 @@ 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
from diligence.database import get_db
from diligence.utils.auth import get_current_user
from diligence.models.user import User
from diligence.services import ai_provider
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ai", tags=["AI Coaching"])

View file

@ -7,12 +7,12 @@ from fastapi.responses import JSONResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.profile import UserProfile
from app.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
from app.utils.auth import hash_password, verify_password, create_access_token, get_current_user
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.profile import UserProfile
from diligence.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS
from diligence.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
from diligence.utils.auth import hash_password, verify_password, create_access_token, get_current_user
logger = logging.getLogger("fitness-rewards")
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -34,6 +34,7 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
display_name=req.display_name,
password_hash=hash_password(req.password),
email=req.email,
timezone=req.timezone or "UTC",
is_admin=is_first_user,
)
db.add(user)

View file

@ -11,13 +11,14 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.program import Program
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
from app.services.program_research import slugify, find_urls_for_program
from app.services.points_engine import log_activity_with_points
from app.utils.auth import get_current_user
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.program import Program
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
from diligence.services.program_research import slugify, find_urls_for_program
from diligence.services.points_engine import log_activity_with_points
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
router = APIRouter(prefix="/api/programs", tags=["programs-v2"])
@ -337,7 +338,7 @@ async def get_program_schedule(
)
# Calculate current real week from start date
today = date.today()
today = today_for_user(user.timezone)
days_elapsed = max(0, (today - program.start_date).days)
current_week = min(total_weeks, (days_elapsed // 7) + 1)
@ -491,7 +492,7 @@ async def complete_workout(
db=db,
user_id=user.id,
category="workout",
activity_date=date.today(),
activity_date=today_for_user(user.timezone),
title=f"{program.name}: Wk{real_week} {workout.workout_name or f'Day {workout.day_number}'}",
description=f"Completed program workout ({len(workout.exercises)} exercises)",
source="program",
@ -556,7 +557,7 @@ async def check_weekly_bonus(
db=db,
user_id=user.id,
category="bonus",
activity_date=date.today(),
activity_date=today_for_user(user.timezone),
title=f"{program.name}: Week {real_week} Complete!",
source="program",
program_id=program.id,
@ -599,7 +600,7 @@ async def check_completion_bonus(
db=db,
user_id=user.id,
category="bonus",
activity_date=date.today(),
activity_date=today_for_user(user.timezone),
title=f"{program.name}: Program Complete!",
source="program",
program_id=program.id,
@ -654,7 +655,7 @@ async def get_program_progress(
)
total_points = result.scalar() or 0
today = date.today()
today = today_for_user(user.timezone)
days_elapsed = max(0, (today - program.start_date).days)
current_week = min(total_weeks, (days_elapsed // 7) + 1)

View file

@ -7,12 +7,13 @@ from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.food import FoodLog
from app.schemas.food import FoodCreate, FoodSearchResult
from app.utils.auth import get_current_user
from app.services.food_lookup import lookup_barcode, search_food
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.food import FoodLog
from diligence.schemas.food import FoodCreate, FoodSearchResult
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
from diligence.services.food_lookup import lookup_barcode, search_food
router = APIRouter(prefix="/api/food", tags=["food"])
@ -49,7 +50,7 @@ async def list_food(
if item.calories:
total_cals += float(item.calories) * float(item.servings or 1)
return {"date": (food_date or date.today()).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)}
return {"date": (food_date or today_for_user(user.timezone)).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)}
@router.post("")

View file

@ -8,14 +8,14 @@ from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.oauth import OAuthToken
from app.utils.auth import get_current_user
from app.services.strava_sync import (
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.oauth import OAuthToken
from diligence.utils.auth import get_current_user
from diligence.services.strava_sync import (
get_strava_auth_url, exchange_strava_code, sync_strava_activities,
)
from app.services.polar_sync import (
from diligence.services.polar_sync import (
get_polar_auth_url, exchange_polar_code, register_polar_user, sync_polar_activities,
)
@ -39,7 +39,7 @@ async def integration_status(
# --- Strava ---
@router.get("/strava/auth")
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
from app.utils.auth import create_access_token
from diligence.utils.auth import create_access_token
from datetime import timedelta
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
return {"auth_url": get_strava_auth_url(state_token)}
@ -52,7 +52,7 @@ async def strava_callback(
db: AsyncSession = Depends(get_db),
):
from jose import JWTError, jwt as jose_jwt
from app.config import get_settings
from diligence.config import get_settings
_settings = get_settings()
try:
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
@ -85,7 +85,7 @@ async def strava_callback(
db.add(token)
await db.flush()
from app.config import get_settings
from diligence.config import get_settings
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?strava=connected")
@ -101,7 +101,7 @@ async def strava_sync(
# --- Polar ---
@router.get("/polar/auth")
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
from app.utils.auth import create_access_token
from diligence.utils.auth import create_access_token
from datetime import timedelta
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
return {"auth_url": get_polar_auth_url(state_token)}
@ -114,7 +114,7 @@ async def polar_callback(
db: AsyncSession = Depends(get_db),
):
from jose import JWTError, jwt as jose_jwt
from app.config import get_settings
from diligence.config import get_settings
_settings = get_settings()
try:
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
@ -144,7 +144,7 @@ async def polar_callback(
db.add(token)
await db.flush()
from app.config import get_settings
from diligence.config import get_settings
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?polar=connected")
@ -178,10 +178,10 @@ async def disconnect(
# --- 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
from diligence.models.integration_config import IntegrationConfig
from diligence.services.provider_registry import PROVIDER_REGISTRY
from diligence.services.crypto import encrypt_value, decrypt_value
from diligence.config import settings
class ConfigureRequest(BaseModel):

View file

@ -10,10 +10,11 @@ from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
from app.utils.auth import get_current_user
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
@ -94,7 +95,7 @@ async def create_meal_plan(
daily_fat_g=body.daily_fat_g,
restrictions=body.restrictions,
duration_days=body.duration_days,
start_date=body.start_date or date.today(),
start_date=body.start_date or today_for_user(user.timezone),
)
db.add(plan)
await db.flush()
@ -131,7 +132,7 @@ async def get_today_meals(
if not plan:
return {"active_plan": None}
day_num = (date.today() - plan.start_date).days + 1
day_num = (today_for_user(user.timezone) - plan.start_date).days + 1
if day_num < 1 or day_num > plan.duration_days:
return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"}
@ -217,7 +218,7 @@ async def log_compliance(
user_id=user.id,
plan_id=plan.id,
plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None,
compliance_date=body.compliance_date or date.today(),
compliance_date=body.compliance_date or today_for_user(user.timezone),
status=body.status,
substitution=body.substitution,
)
@ -250,7 +251,7 @@ async def get_plan_progress(
substituted = sum(1 for e in entries if e.status == "substituted")
skipped = sum(1 for e in entries if e.status == "skipped")
days_elapsed = (date.today() - plan.start_date).days + 1
days_elapsed = (today_for_user(user.timezone) - plan.start_date).days + 1
compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0
return {

View file

@ -10,15 +10,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
from app.models.food import FoodLog
from app.schemas.nutrition import (
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
from diligence.models.food import FoodLog
from diligence.schemas.nutrition import (
NutritionGoalIn, NutritionGoalOut, FastStart, FastUpdate, FastOut, ElectrolyteIn,
)
from app.utils.auth import get_current_user
from app.services.points_engine import log_activity_with_points
from diligence.utils.auth import get_current_user
from diligence.services.points_engine import log_activity_with_points
from diligence.utils.dates import today_for_user, now_for_user, day_start_utc
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
@ -87,7 +88,7 @@ async def get_today(
):
"""Today's macros vs target, eating-window state, active fast, compliance."""
goal = await _get_or_create_goal(db, user.id)
today = date.today()
today = today_for_user(user.timezone)
# Sum today's food
result = await db.execute(
@ -103,8 +104,8 @@ async def get_today(
cals, prot, carbs, fat, fiber = float(cals), float(prot), float(carbs), float(fat), float(fiber)
net_carbs = max(0.0, carbs - fiber)
# Eating window (local time, using goal timezone_str — simplified: assume server in UTC, user sends local)
now_local = datetime.now(timezone.utc) + timedelta(hours=7) # Asia/Bangkok offset hack
# Eating window (user's local time)
now_local = now_for_user(user.timezone)
hour = now_local.hour
in_window = goal.eating_window_start_hour <= hour < goal.eating_window_end_hour
window_str = f"{goal.eating_window_start_hour:02d}:00{goal.eating_window_end_hour:02d}:00"
@ -352,7 +353,7 @@ async def get_electrolytes_today(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
today_start = datetime.combine(date.today(), datetime.min.time()).replace(tzinfo=timezone.utc)
today_start = day_start_utc(user.timezone)
result = await db.execute(
select(
func.coalesce(func.sum(ElectrolyteLog.sodium_mg), 0),

View file

@ -6,13 +6,13 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.profile import UserProfile
from app.models.points import DailyTarget, TTM_DAILY_TARGETS
from app.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse
from app.utils.auth import get_current_user
from app.services.resource_matcher import get_recommendations
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.profile import UserProfile
from diligence.models.points import DailyTarget, TTM_DAILY_TARGETS
from diligence.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse
from diligence.utils.auth import get_current_user
from diligence.services.resource_matcher import get_recommendations
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])

View file

@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward
from app.schemas.points import PointRuleUpdate, DailyTargetUpdate
from app.schemas.reward import RewardCreate, RewardRedeemRequest
from app.utils.auth import get_current_user
from app.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.points import PointRule, DailyTarget
from diligence.models.reward import Reward
from diligence.schemas.points import PointRuleUpdate, DailyTargetUpdate
from diligence.schemas.reward import RewardCreate, RewardRedeemRequest
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
from diligence.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
router = APIRouter(prefix="/api/points", tags=["points"])
@ -24,7 +25,7 @@ async def today_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
return await get_today_status(db, user.id)
return await get_today_status(db, user.id, tz_str=user.timezone)
@router.get("/week")
@ -33,7 +34,7 @@ async def week_summary(
db: Annotated[AsyncSession, Depends(get_db)],
start: date | None = None,
):
d = start or date.today()
d = start or today_for_user(user.timezone)
return await get_weekly_summary(db, user.id, d)
@ -143,7 +144,7 @@ async def redeem(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date)
result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date, tz_str=user.timezone)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["reason"])
return result

View file

@ -7,11 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.program import Program
from app.models.activity import ActivityLog
from app.utils.auth import get_current_user
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.program import Program
from diligence.models.activity import ActivityLog
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
from pydantic import BaseModel
router = APIRouter(prefix="/api/programs", tags=["programs"])
@ -37,7 +38,7 @@ async def list_programs(
programs = result.scalars().all()
out = []
for p in programs:
today = date.today()
today = today_for_user(user.timezone)
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1
out.append({
@ -90,7 +91,7 @@ async def get_program(
)
logged = days_logged.scalar() or 0
today = date.today()
today = today_for_user(user.timezone)
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1

View file

@ -13,13 +13,14 @@ from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.support import SupportThread, SupportMessage
from app.models.activity import ActivityLog
from app.services.points_engine import get_active_program, get_today_status
from app.utils.auth import get_current_user
from app.config import settings
from diligence.database import get_db
from diligence.models.user import User
from diligence.models.support import SupportThread, SupportMessage
from diligence.models.activity import ActivityLog
from diligence.services.points_engine import get_active_program, get_today_status
from diligence.utils.auth import get_current_user
from diligence.utils.dates import day_start_utc
from diligence.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/support", tags=["support"])
@ -45,7 +46,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict:
}
try:
program = await get_active_program(db, user.id)
program = await get_active_program(db, user.id, tz_str=user.timezone)
if program:
context["program_name"] = program.get("name")
context["program_day"] = program.get("day")
@ -57,7 +58,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict:
pass
try:
status = await get_today_status(db, user.id)
status = await get_today_status(db, user.id, tz_str=user.timezone)
context["points_today"] = status.get("points_earned", 0)
context["daily_target"] = status.get("daily_minimum", 80)
context["gate_passed"] = status.get("gate_passed", False)
@ -219,7 +220,7 @@ async def send_message(
raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)")
# Rate limit check
today_start = datetime.combine(date.today(), datetime.min.time(), tzinfo=timezone.utc)
today_start = day_start_utc(user.timezone)
thread = await get_or_create_thread(db, user.id)
result = await db.execute(
@ -301,7 +302,7 @@ async def list_threads(
out = []
for t in threads:
# Get the user's display name
from app.models.user import User as UserModel
from diligence.models.user import User as UserModel
user_result = await db.execute(
select(UserModel).where(UserModel.id == t.user_id)
)
@ -360,7 +361,7 @@ async def get_admin_thread(
await db.flush()
# Get user info
from app.models.user import User as UserModel
from diligence.models.user import User as UserModel
user_result = await db.execute(
select(UserModel).where(UserModel.id == thread.user_id)
)

View file

@ -13,6 +13,7 @@ class RegisterRequest(BaseModel):
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
email: str | None = None
timezone: str | None = None
class TokenResponse(BaseModel):

View file

@ -18,9 +18,9 @@ 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
from diligence.config import get_settings
from diligence.services.crypto import decrypt_value
from diligence.models.integration_config import IntegrationConfig
logger = logging.getLogger(__name__)
settings = get_settings()
@ -101,8 +101,8 @@ async def get_active_ai_provider(db: AsyncSession, user_id=None) -> dict | 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
from diligence.models.user import User
from diligence.models.profile import UserProfile
context_parts = []
@ -122,7 +122,7 @@ async def build_context(db: AsyncSession, user_id) -> str:
# Today's points (best effort)
try:
from app.services.points_engine import get_daily_summary
from diligence.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'}")

View file

@ -8,9 +8,9 @@ from datetime import datetime, timezone
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session
from app.models.catalog import CrawlQueue
from app.services.program_research import process_crawl_job
from diligence.database import async_session
from diligence.models.catalog import CrawlQueue
from diligence.services.program_research import process_crawl_job
logger = logging.getLogger(__name__)

View file

@ -17,11 +17,11 @@ 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
from diligence.config import get_settings
from diligence.models.oauth import OAuthToken
from diligence.models.integration_config import IntegrationConfig
from diligence.services.crypto import decrypt_value
from diligence.services.points_engine import log_activity_with_points
logger = logging.getLogger(__name__)
settings = get_settings()
@ -146,7 +146,7 @@ class DeviceSyncBase(abc.ABC):
Deduplicates by (user_id, provider, external_id).
"""
from app.models.activity import ActivityLog
from diligence.models.activity import ActivityLog
if external_id:
existing = await db.execute(

View file

@ -2,7 +2,7 @@ from __future__ import annotations
"""Open Food Facts API client for barcode scanning and food search."""
import httpx
from app.schemas.food import FoodSearchResult
from diligence.schemas.food import FoodSearchResult
OFF_BASE = "https://world.openfoodfacts.org"
USER_AGENT = "Diligence/1.0"

View file

@ -6,11 +6,11 @@ from datetime import date, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.activity import ActivityLog
from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward, RewardRedemption
from app.models.program import Program
from app.utils.dates import get_week_boundaries
from diligence.models.activity import ActivityLog
from diligence.models.points import PointRule, DailyTarget
from diligence.models.reward import Reward, RewardRedemption
from diligence.models.program import Program
from diligence.utils.dates import get_week_boundaries
async def get_daily_points_earned(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
@ -81,7 +81,7 @@ async def get_available_rewards(db: AsyncSession, user_id: uuid.UUID) -> list[di
]
async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
async def get_active_program(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict | None:
result = await db.execute(
select(Program)
.where(Program.user_id == user_id, Program.status == "active")
@ -90,7 +90,7 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non
program = result.scalar_one_or_none()
if not program:
return None
today = date.today()
today = today_for_user(tz_str)
day_num = (today - program.start_date).days + 1
total = (program.end_date - program.start_date).days + 1
return {
@ -104,8 +104,8 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non
}
async def get_today_status(db: AsyncSession, user_id: uuid.UUID) -> dict:
today = date.today()
async def get_today_status(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict:
today = today_for_user(tz_str)
earned = await get_daily_points_earned(db, user_id, today)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
@ -189,9 +189,9 @@ async def log_activity_with_points(
async def redeem_reward(
db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None
) -> dict:
, tz_str: str = "UTC") -> dict:
"""Attempt to redeem a reward. Returns success/failure with details."""
d = redemption_date or date.today()
d = redemption_date or today_for_user(tz_str)
earned = await get_daily_points_earned(db, user_id, d)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80

View file

@ -3,14 +3,15 @@ from __future__ import annotations
"""Polar AccessLink API client for activity sync."""
import uuid
from datetime import datetime, timezone
from diligence.utils.dates import today_for_user
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.activity import ActivityLog
from app.services.points_engine import log_activity_with_points
from diligence.config import get_settings
from diligence.models.oauth import OAuthToken
from diligence.models.activity import ActivityLog
from diligence.services.points_engine import log_activity_with_points
settings = get_settings()
@ -108,7 +109,7 @@ async def sync_polar_activities(db: AsyncSession, user_id: uuid.UUID) -> list[di
activity_date = datetime.fromisoformat(start).date()
except (ValueError, TypeError):
from datetime import date
activity_date = date.today()
activity_date = today_for_user()
# Parse duration ISO 8601 (e.g., PT1H30M)
duration_str = ex.get("duration", "")

View file

@ -12,8 +12,8 @@ import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue
from app.config import settings
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue
from diligence.config import settings
# ── Known program sources (skip search for these) ──────────────────────────

View file

@ -4,8 +4,8 @@ from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.resource import Resource
from app.models.profile import UserProfile
from diligence.models.resource import Resource
from diligence.models.profile import UserProfile
# Equipment categories that satisfy each resource requirement

View file

@ -7,10 +7,10 @@ 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.activity import ActivityLog
from app.services.points_engine import log_activity_with_points
from diligence.config import get_settings
from diligence.models.oauth import OAuthToken
from diligence.models.activity import ActivityLog
from diligence.services.points_engine import log_activity_with_points
settings = get_settings()

View file

@ -9,8 +9,8 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db
from diligence.config import get_settings
from diligence.database import get_db
settings = get_settings()
ALGORITHM = "HS256" # Hardcoded — not configurable for security
@ -44,7 +44,7 @@ async def get_current_user(
if not token:
raise credentials_exception
from app.models.user import User
from diligence.models.user import User
# Check if this is an API token (MCP connector auth)
if settings.api_token and token == settings.api_token:

42
diligence/utils/dates.py Normal file
View file

@ -0,0 +1,42 @@
"""Timezone-aware date helpers.
All date calculations should use these helpers instead of date.today()
so the result reflects the user's local date, not the server's.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
def today_for_user(tz_str: str = "UTC") -> date:
"""Return today's date in the user's timezone."""
try:
tz = ZoneInfo(tz_str)
except (KeyError, Exception):
tz = ZoneInfo("UTC")
return datetime.now(tz).date()
def now_for_user(tz_str: str = "UTC") -> datetime:
"""Return the current datetime in the user's timezone (tz-aware)."""
try:
tz = ZoneInfo(tz_str)
except (KeyError, Exception):
tz = ZoneInfo("UTC")
return datetime.now(tz)
def day_start_utc(tz_str: str = "UTC") -> datetime:
"""Return the start of today (midnight) in the user's timezone, converted to UTC.
Useful for database queries that store timestamps in UTC but need
to filter by the user's local day.
"""
user_today = today_for_user(tz_str)
try:
tz = ZoneInfo(tz_str)
except (KeyError, Exception):
tz = ZoneInfo("UTC")
local_midnight = datetime(user_today.year, user_today.month, user_today.day, tzinfo=tz)
return local_midnight.astimezone(ZoneInfo("UTC"))

View file

@ -15,10 +15,13 @@ services:
start_period: 10s
backend:
build: ./backend
build:
context: .
dockerfile: backend/Dockerfile
restart: unless-stopped
env_file: .env
environment:
- DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
- BASE_URL=${BASE_URL:-http://localhost}
- API_TOKEN=${API_TOKEN:-}
depends_on:

View file

@ -6,7 +6,7 @@
<meta name="theme-color" content="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>Fitness Rewards</title>
<title>Diligence</title>
</head>
<body>
<div id="root"></div>

1865
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ import CatalogDetail from './pages/CatalogDetail'
import Support from './pages/Support'
import SupportAdmin from './pages/SupportAdmin'
import ChatCoach from './pages/ChatCoach'
import AgentConnect from './pages/AgentConnect'
function ProtectedRoute({ children }) {
if (!hasToken()) return <Navigate to="/login" />
@ -95,6 +96,9 @@ function NavBar() {
<NavLink to="/chat" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🧠</span> Coach
</NavLink>
<NavLink to="/agent" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🔌</span> Agent
</NavLink>
</nav>
)
}
@ -123,6 +127,7 @@ export default function App() {
<Route path="/settings/integrations" element={<ProtectedRoute><SettingsIntegrations /></ProtectedRoute>} />
<Route path="/meal-plan" element={<ProtectedRoute><MealPlan /></ProtectedRoute>} />
<Route path="/chat" element={<ProtectedRoute><ChatCoach /></ProtectedRoute>} />
<Route path="/agent" element={<ProtectedRoute><AgentConnect /></ProtectedRoute>} />
</Routes>
{hasToken() && <NavBar />}
</>

View file

@ -30,7 +30,7 @@ export const api = {
login: (username, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
register: (username, password, display_name) =>
request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name }) }),
request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }) }),
me: () => request('/auth/me'),
// Onboarding
@ -145,6 +145,9 @@ export const api = {
// Resources
getResourceRecommendations: () => request('/onboarding/recommendations'),
// Agent
agentConfig: () => request('/agent/config'),
};
export function setToken(token) {

View file

@ -41,6 +41,12 @@
--r-lg: 12px;
--r-full: 999px;
/* Backwards-compat aliases */
--surface: var(--card);
--surface-2: var(--card);
--border: var(--card-border);
--r-md: var(--r);
--font: 'Instrument Sans', -apple-system, sans-serif;
--font-display: 'Instrument Sans', -apple-system, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
@ -233,7 +239,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.03em;
padding: 6px 14px;
padding: 6px 10px;
text-decoration: none;
transition: color 0.15s;
border-radius: var(--r-sm);

View file

@ -0,0 +1,208 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
function CopyButton({ text, label }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* fallback for non-HTTPS */ }
}
return (
<button
onClick={handleCopy}
className="btn-outline btn-sm"
style={{ fontSize: '0.75rem', padding: '6px 12px', whiteSpace: 'nowrap' }}
>
{copied ? 'Copied' : (label || 'Copy')}
</button>
)
}
function CodeBlock({ children, copyText }) {
return (
<div style={{
background: 'var(--bg)', border: '1px solid var(--divider)',
borderRadius: 'var(--r-sm)', padding: '14px 16px',
fontFamily: 'var(--font-mono)', fontSize: '0.78rem', lineHeight: 1.6,
overflowX: 'auto', position: 'relative', whiteSpace: 'pre',
}}>
{copyText && (
<div style={{ position: 'absolute', top: '8px', right: '8px' }}>
<CopyButton text={copyText} />
</div>
)}
{children}
</div>
)
}
export default function AgentConnect() {
const [config, setConfig] = useState(null)
const [loading, setLoading] = useState(true)
const [showToken, setShowToken] = useState(false)
useEffect(() => {
api.agentConfig()
.then(setConfig)
.catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
if (!config) return <div className="page"><div className="error-msg">Failed to load agent config</div></div>
const maskedToken = config.api_token
? config.api_token.slice(0, 8) + '\u2026' + config.api_token.slice(-4)
: null
const claudeDesktopConfig = JSON.stringify({
"mcpServers": {
"diligence": {
"url": config.mcp_url,
...(config.api_token ? { "headers": { "Authorization": `Bearer ${config.api_token}` } } : {})
}
}
}, null, 2)
return (
<div className="page">
<div style={{ marginBottom: '20px' }}>
<h1 style={{
fontFamily: 'var(--font-display)', fontWeight: 800,
fontSize: '1.3rem', marginBottom: '4px',
}}>
Connect Your AI Agent
</h1>
<p style={{ color: 'var(--text-3)', fontSize: '0.85rem' }}>
Use any MCP-compatible AI to log workouts, track food, and manage your fitness.
</p>
</div>
{/* Connection Details */}
<div className="card">
<div className="section-label">Connection</div>
<div style={{ marginBottom: '14px' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', fontWeight: 600, marginBottom: '4px' }}>
MCP Endpoint
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
background: 'var(--bg)', borderRadius: 'var(--r-sm)',
padding: '10px 12px', border: '1px solid var(--divider)',
}}>
<code style={{
flex: 1, fontFamily: 'var(--font-mono)', fontSize: '0.82rem',
color: 'var(--accent)', wordBreak: 'break-all',
}}>
{config.mcp_url}
</code>
<CopyButton text={config.mcp_url} />
</div>
</div>
{config.api_token && (
<div style={{ marginBottom: '14px' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', fontWeight: 600, marginBottom: '4px' }}>
API Token
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
background: 'var(--bg)', borderRadius: 'var(--r-sm)',
padding: '10px 12px', border: '1px solid var(--divider)',
}}>
<code
onClick={() => setShowToken(!showToken)}
style={{
flex: 1, fontFamily: 'var(--font-mono)', fontSize: '0.82rem',
cursor: 'pointer', wordBreak: 'break-all',
}}
>
{showToken ? config.api_token : maskedToken}
</code>
<CopyButton text={config.api_token} />
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-3)', marginTop: '4px' }}>
Tap token to reveal. Only visible to admins.
</div>
</div>
)}
{!config.api_token && config.api_token_set && (
<div style={{
fontSize: '0.8rem', color: 'var(--text-3)', fontStyle: 'italic',
padding: '8px 0',
}}>
API token is set but only visible to admin users.
</div>
)}
<div style={{
display: 'flex', gap: '12px', flexWrap: 'wrap',
fontSize: '0.78rem', color: 'var(--text-3)',
}}>
<span>{config.tools_count} tools available</span>
<span style={{ color: 'var(--divider)' }}>|</span>
<span>{config.deployment === 'local' ? 'Local (SQLite)' : 'Docker (PostgreSQL)'}</span>
</div>
</div>
{/* Claude Desktop Setup */}
<div className="card">
<div className="section-label">Claude Desktop</div>
<p style={{ fontSize: '0.82rem', color: 'var(--text-2)', marginBottom: '12px' }}>
Add this to your Claude Desktop MCP config:
</p>
<CodeBlock copyText={claudeDesktopConfig}>
{claudeDesktopConfig}
</CodeBlock>
<p style={{ fontSize: '0.75rem', color: 'var(--text-3)', marginTop: '10px' }}>
On macOS: <code style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
~/Library/Application Support/Claude/claude_desktop_config.json
</code>
</p>
</div>
{/* Claude Code Setup */}
<div className="card">
<div className="section-label">Claude Code</div>
<p style={{ fontSize: '0.82rem', color: 'var(--text-2)', marginBottom: '12px' }}>
Add the MCP server from your terminal:
</p>
<CodeBlock copyText={`claude mcp add diligence --transport sse ${config.mcp_url}`}>
{`claude mcp add diligence --transport sse ${config.mcp_url}`}
</CodeBlock>
</div>
{/* What your agent can do */}
<div className="card">
<div className="section-label">What Your Agent Can Do</div>
<div style={{ display: 'grid', gap: '8px' }}>
{[
['Log workouts', 'Track any activity and earn points automatically'],
['Track food', 'Search 400K+ foods, log meals with full macros'],
['Manage meal plans', 'Create plans, track compliance, adjust portions'],
['Check progress', 'Daily points, weekly summary, program status'],
['Redeem rewards', 'Spend earned points on rewards you set up'],
].map(([title, desc]) => (
<div key={title} style={{
display: 'flex', gap: '10px', alignItems: 'flex-start',
padding: '8px 0', borderBottom: '1px solid var(--divider)',
}}>
<span style={{ color: 'var(--green)', fontSize: '0.9rem', marginTop: '1px' }}>
&#10003;
</span>
<div>
<div style={{ fontWeight: 700, fontSize: '0.85rem' }}>{title}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-3)' }}>{desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}

View file

@ -36,12 +36,12 @@ export default function Login() {
<div style={{ width: '100%', maxWidth: '380px' }}>
{/* Brand */}
<div style={{ textAlign: 'center', marginBottom: '32px', color: '#fff' }}>
<div style={{ fontSize: '3.2rem', marginBottom: '8px', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.2))' }}>🔥</div>
<div style={{ fontSize: '3.2rem', marginBottom: '8px', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.2))' }}>💪</div>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '2.2rem', fontWeight: 900, letterSpacing: '-0.03em' }}>
Fitness Rewards
Diligence
</h1>
<p style={{ opacity: 0.8, marginTop: '4px', fontSize: '0.95rem', fontWeight: 500 }}>
Earn your rewards. Every single day.
Your fitness. Your data. Your rewards.
</p>
</div>

65
pyproject.toml Normal file
View file

@ -0,0 +1,65 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "diligence"
version = "2.0.0"
description = "Self-hosted fitness rewards platform with AI agent integration"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
authors = [
{name = "DiligenceWorks Pte. Ltd.", email = "hello@diligenceworks.online"},
]
keywords = ["fitness", "self-hosted", "mcp", "ai-agent", "rewards"]
classifiers = [
"Development Status :: 4 - Beta",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy[asyncio]>=2.0.30",
"aiosqlite>=0.20.0",
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.2.0",
"httpx>=0.27.0",
"python-multipart>=0.0.18",
]
[project.optional-dependencies]
postgres = [
"asyncpg>=0.30.0",
]
mcp = [
"mcp[cli]>=1.0.0",
]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.8.0",
]
[project.scripts]
diligence = "diligence.cli:main"
[project.urls]
Homepage = "https://github.com/DiligenceWorks/Diligence"
Documentation = "https://github.com/DiligenceWorks/Diligence#readme"
Repository = "https://github.com/DiligenceWorks/Diligence"
Issues = "https://github.com/DiligenceWorks/Diligence/issues"
[tool.setuptools.packages.find]
include = ["diligence*"]
[tool.setuptools.package-data]
diligence = ["frontend/**/*"]

View file

@ -1,29 +1,71 @@
# 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
# Diligence Setup — Windows
Write-Host ""
Write-Host " Diligence Setup"
Write-Host ""
Write-Host "`n`e[36m💪 Diligence — Setup`e[0m`n"
function New-RandomHex { -join ((1..32) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) }) }
if (Test-Path .env) {
Write-Host "`e[33m.env already exists. Delete it first to regenerate.`e[0m"
exit 1
$Secret = New-RandomHex
$Token = New-RandomHex
# Detect mode — Docker if docker compose is available and docker-compose.yml exists
$DockerAvailable = $false
try { docker compose version 2>$null | Out-Null; $DockerAvailable = $true } catch {}
if ($DockerAvailable -and (Test-Path "docker-compose.yml")) {
Write-Host " Mode: Docker (PostgreSQL)"
Write-Host ""
if (Test-Path ".env") {
Write-Host " .env already exists. Delete it first to regenerate."
exit 1
}
Copy-Item ".env.example" ".env"
(Get-Content ".env") -replace '^SECRET_KEY=.*', "SECRET_KEY=$Secret" `
-replace '^API_TOKEN=.*', "API_TOKEN=$Token" `
-replace '^DATABASE_URL=.*', 'DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards' `
-replace '^BASE_URL=.*', 'BASE_URL=http://localhost' |
Set-Content ".env"
Write-Host " .env created"
Write-Host ""
Write-Host " Next steps:"
Write-Host " docker compose up -d"
Write-Host " Open http://localhost"
} else {
Write-Host " Mode: Local (SQLite)"
Write-Host ""
$DataDir = Join-Path $env:USERPROFILE ".diligence"
if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Path $DataDir | Out-Null }
$EnvFile = Join-Path $DataDir ".env"
if (Test-Path $EnvFile) {
Write-Host " Config already exists at $EnvFile"
Write-Host " Delete it to regenerate."
exit 1
}
@"
SECRET_KEY=$Secret
API_TOKEN=$Token
BASE_URL=http://localhost:8000
DATA_DIR=$DataDir
"@ | Set-Content $EnvFile
Write-Host " Config created at $EnvFile"
Write-Host ""
Write-Host " Next steps:"
Write-Host " pip install ."
Write-Host " diligence"
Write-Host ""
Write-Host " Data stored in: $DataDir"
}
# 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 " MCP agent connection:"
Write-Host " URL: http://localhost:3001/sse"
Write-Host " Header: Authorization: Bearer $Token"
Write-Host ""

View file

@ -1,26 +1,81 @@
#!/bin/bash
echo "💪 Diligence — Setup"
set -e
echo ""
echo " Diligence Setup"
echo ""
if [ -f .env ]; then
echo ".env already exists. Delete it first to regenerate."
exit 1
# Detect install mode
if command -v docker compose &> /dev/null && [ -f docker-compose.yml ]; then
MODE="docker"
else
MODE="local"
fi
SECRET=$(openssl rand -hex 32)
TOKEN=$(openssl rand -hex 32)
cp .env.example .env
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env
rm -f .env.bak
# Generate secrets
SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
TOKEN=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
if [ "$MODE" = "docker" ]; then
echo " Mode: Docker (PostgreSQL)"
echo ""
if [ -f .env ]; then
echo " .env already exists. Delete it first to regenerate."
exit 1
fi
cp .env.example .env
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env
sed -i.bak "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards|" .env
sed -i.bak "s|^BASE_URL=.*|BASE_URL=http://localhost|" .env
rm -f .env.bak
echo " .env created"
echo ""
echo " Next steps:"
echo " docker compose up -d"
echo " Open http://localhost"
echo ""
echo " MCP agent connection:"
echo " URL: http://localhost:3001/sse"
echo " Header: Authorization: Bearer ${TOKEN}"
else
echo " Mode: Local (SQLite)"
echo ""
DATA_DIR="${HOME}/.diligence"
mkdir -p "$DATA_DIR"
ENV_FILE="${DATA_DIR}/.env"
if [ -f "$ENV_FILE" ]; then
echo " Config already exists at ${ENV_FILE}"
echo " Delete it to regenerate."
exit 1
fi
cat > "$ENV_FILE" << EOF
SECRET_KEY=${SECRET}
API_TOKEN=${TOKEN}
BASE_URL=http://localhost:8000
DATA_DIR=${DATA_DIR}
EOF
echo " Config created at ${ENV_FILE}"
echo ""
echo " Next steps:"
echo " pip install ."
echo " diligence"
echo ""
echo " Or run directly:"
echo " python -m diligence"
echo ""
echo " Data stored in: ${DATA_DIR}"
echo ""
echo " MCP agent connection:"
echo " URL: http://localhost:3001/sse"
echo " Header: Authorization: Bearer ${TOKEN}"
fi
echo "✅ .env created with random SECRET_KEY and API_TOKEN"
echo ""
echo "Next steps:"
echo " docker compose up -d"
echo " Open http://localhost"
echo " Register your account"
echo " Configure integrations via Settings or your AI agent"
echo ""
echo "MCP agent connection:"
echo " URL: http://localhost:3001/sse"
echo " Header: Authorization: Bearer ${TOKEN}"