Initial commit: full fitness-rewards app

Backend: FastAPI + PostgreSQL + SQLAlchemy async
- Auth (JWT), onboarding (PAR-Q+, TTM, BREQ-2), activities, food log
- Points engine with daily gate + weekly targets
- Strava + Polar OAuth integration
- Open Food Facts barcode lookup
- Resource recommendation engine
- 10 seeded Darebee/StrongLifts/YouTube programs

Frontend: React + Vite, mobile-first dark theme
- Login/Register, 8-step onboarding flow
- Dashboard with daily gate status
- Activity logger, food logger (manual + barcode scan + search)
- Reward shop with redemption
- Weekly summary view
- Settings (point rules, targets, integrations)

Deployment: Docker Compose for Coolify (Traefik)
This commit is contained in:
claude 2026-03-14 23:53:00 +00:00
commit 4db2b0846b
61 changed files with 4280 additions and 0 deletions

View file

View file

@ -0,0 +1,76 @@
"""Open Food Facts API client for barcode scanning and food search."""
import httpx
from app.schemas.food import FoodSearchResult
OFF_BASE = "https://world.openfoodfacts.org"
USER_AGENT = "FitnessRewards/1.0 (fitness.littlefake.com)"
async def lookup_barcode(barcode: str) -> FoodSearchResult | None:
"""Lookup a product by barcode from Open Food Facts."""
url = f"{OFF_BASE}/api/v2/product/{barcode}.json"
params = {"fields": "product_name,brands,nutriments,serving_size,image_front_small_url"}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, params=params, headers={"User-Agent": USER_AGENT})
if resp.status_code != 200:
return None
data = resp.json()
if data.get("status") != 1 or "product" not in data:
return None
p = data["product"]
n = p.get("nutriments", {})
return FoodSearchResult(
barcode=barcode,
product_name=p.get("product_name"),
brand=p.get("brands"),
calories_100g=n.get("energy-kcal_100g"),
protein_100g=n.get("proteins_100g"),
carbs_100g=n.get("carbohydrates_100g"),
fat_100g=n.get("fat_100g"),
fiber_100g=n.get("fiber_100g"),
sugar_100g=n.get("sugars_100g"),
serving_size=p.get("serving_size"),
image_url=p.get("image_front_small_url"),
)
async def search_food(query: str, page: int = 1) -> list[FoodSearchResult]:
"""Search for food products by name."""
url = f"{OFF_BASE}/cgi/search.pl"
params = {
"search_terms": query,
"search_simple": 1,
"action": "process",
"json": 1,
"page_size": 10,
"page": page,
"fields": "code,product_name,brands,nutriments,serving_size,image_front_small_url",
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, params=params, headers={"User-Agent": USER_AGENT})
if resp.status_code != 200:
return []
data = resp.json()
results = []
for p in data.get("products", []):
n = p.get("nutriments", {})
results.append(FoodSearchResult(
barcode=p.get("code"),
product_name=p.get("product_name"),
brand=p.get("brands"),
calories_100g=n.get("energy-kcal_100g"),
protein_100g=n.get("proteins_100g"),
carbs_100g=n.get("carbohydrates_100g"),
fat_100g=n.get("fat_100g"),
fiber_100g=n.get("fiber_100g"),
sugar_100g=n.get("sugars_100g"),
serving_size=p.get("serving_size"),
image_url=p.get("image_front_small_url"),
))
return results

View file

@ -0,0 +1,268 @@
"""Points calculation engine — the heart of the reward system."""
import uuid
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
async def get_daily_points_earned(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
result = await db.execute(
select(func.coalesce(func.sum(ActivityLog.points_earned), 0))
.where(ActivityLog.user_id == user_id, ActivityLog.activity_date == d)
)
return result.scalar()
async def get_daily_points_spent(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
result = await db.execute(
select(func.coalesce(func.sum(RewardRedemption.points_spent), 0))
.where(RewardRedemption.user_id == user_id, RewardRedemption.redemption_date == d)
)
return result.scalar()
async def get_daily_target(db: AsyncSession, user_id: uuid.UUID) -> DailyTarget | None:
result = await db.execute(
select(DailyTarget).where(DailyTarget.user_id == user_id, DailyTarget.is_active == True)
)
return result.scalar_one_or_none()
async def get_week_points(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
mon, sun = get_week_boundaries(d)
result = await db.execute(
select(func.coalesce(func.sum(ActivityLog.points_earned), 0))
.where(
ActivityLog.user_id == user_id,
ActivityLog.activity_date >= mon,
ActivityLog.activity_date <= sun,
)
)
return result.scalar()
async def get_activities_for_date(db: AsyncSession, user_id: uuid.UUID, d: date) -> list[dict]:
result = await db.execute(
select(ActivityLog)
.where(ActivityLog.user_id == user_id, ActivityLog.activity_date == d)
.order_by(ActivityLog.logged_at.desc())
)
activities = result.scalars().all()
return [
{
"id": str(a.id),
"category": a.category,
"title": a.title,
"points_earned": a.points_earned,
"duration_minutes": a.duration_minutes,
"source": a.source,
"logged_at": a.logged_at.isoformat() if a.logged_at else None,
}
for a in activities
]
async def get_available_rewards(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
result = await db.execute(
select(Reward).where(Reward.user_id == user_id, Reward.is_active == True)
)
rewards = result.scalars().all()
return [
{"id": str(r.id), "name": r.name, "point_cost": r.point_cost, "description": r.description}
for r in rewards
]
async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
result = await db.execute(
select(Program)
.where(Program.user_id == user_id, Program.status == "active")
.order_by(Program.created_at.desc())
)
program = result.scalar_one_or_none()
if not program:
return None
today = date.today()
day_num = (today - program.start_date).days + 1
total = (program.end_date - program.start_date).days + 1
return {
"id": str(program.id),
"name": program.name,
"day": min(day_num, total),
"total_days": total,
"start_date": program.start_date.isoformat(),
"end_date": program.end_date.isoformat(),
"status": program.status,
}
async def get_today_status(db: AsyncSession, user_id: uuid.UUID) -> dict:
today = date.today()
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
weekly_tgt = target.weekly_target_pts if target else 500
gate_passed = earned >= daily_min
activities = await get_activities_for_date(db, user_id, today)
rewards = await get_available_rewards(db, user_id) if gate_passed else []
week_pts = await get_week_points(db, user_id, today)
program = await get_active_program(db, user_id)
return {
"date": today.isoformat(),
"points_earned": earned,
"daily_minimum": daily_min,
"gate_passed": gate_passed,
"points_remaining": max(0, daily_min - earned),
"activities_today": activities,
"rewards_available": rewards,
"week_points": week_pts,
"weekly_target": weekly_tgt,
"program_day": program["day"] if program else None,
"program_total_days": program["total_days"] if program else None,
"program_name": program["name"] if program else None,
}
async def log_activity_with_points(
db: AsyncSession,
user_id: uuid.UUID,
category: str,
activity_date: date,
title: str | None = None,
description: str | None = None,
duration_minutes: int | None = None,
source: str = "manual",
external_id: str | None = None,
program_id: uuid.UUID | None = None,
program_day: int | None = None,
metadata: dict | None = None,
) -> ActivityLog:
"""Log an activity and auto-calculate points from active rules."""
# Find matching point rule
result = await db.execute(
select(PointRule).where(
PointRule.user_id == user_id,
PointRule.category == category,
PointRule.is_active == True,
)
)
rule = result.scalar_one_or_none()
points = 0
rule_id = None
if rule:
rule_id = rule.id
if rule.unit == "per_hour" and duration_minutes:
points = rule.points * (duration_minutes / 60)
else:
points = rule.points
activity = ActivityLog(
user_id=user_id,
category=category,
title=title,
description=description,
duration_minutes=duration_minutes,
source=source,
external_id=external_id,
points_earned=int(points),
rule_id=rule_id,
activity_date=activity_date,
program_id=program_id,
program_day=program_day,
metadata_json=metadata or {},
)
db.add(activity)
await db.flush()
return activity
async def redeem_reward(
db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None
) -> dict:
"""Attempt to redeem a reward. Returns success/failure with details."""
d = redemption_date or date.today()
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
if earned < daily_min:
return {"success": False, "reason": f"Need {daily_min} points today, have {earned}"}
result = await db.execute(select(Reward).where(Reward.id == reward_id, Reward.user_id == user_id))
reward = result.scalar_one_or_none()
if not reward:
return {"success": False, "reason": "Reward not found"}
redemption = RewardRedemption(
user_id=user_id,
reward_id=reward_id,
points_spent=reward.point_cost,
redemption_date=d,
)
db.add(redemption)
await db.flush()
return {
"success": True,
"id": str(redemption.id),
"reward_name": reward.name,
"points_spent": reward.point_cost,
"redemption_date": d.isoformat(),
}
async def get_weekly_summary(db: AsyncSession, user_id: uuid.UUID, d: date) -> dict:
"""Generate weekly summary for the week containing date d."""
mon, sun = get_week_boundaries(d)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
weekly_tgt = target.weekly_target_pts if target else 500
bonus = target.weekly_bonus_pts if target else 50
daily_breakdown = []
total_earned = 0
total_spent = 0
active_days = 0
gate_days = 0
for i in range(7):
day = mon + timedelta(days=i)
earned = await get_daily_points_earned(db, user_id, day)
spent = await get_daily_points_spent(db, user_id, day)
gate = earned >= daily_min
total_earned += earned
total_spent += spent
if earned > 0:
active_days += 1
if gate:
gate_days += 1
daily_breakdown.append({
"date": day.isoformat(),
"points_earned": earned,
"points_spent": spent,
"gate_passed": gate,
"daily_minimum": daily_min,
})
hit_target = total_earned >= weekly_tgt
return {
"week_start": mon.isoformat(),
"week_end": sun.isoformat(),
"total_points_earned": total_earned,
"total_points_spent": total_spent,
"active_days": active_days,
"gate_passed_days": gate_days,
"hit_weekly_target": hit_target,
"weekly_target": weekly_tgt,
"weekly_bonus_earned": bonus if hit_target else 0,
"daily_breakdown": daily_breakdown,
}

View file

@ -0,0 +1,145 @@
"""Polar AccessLink API client for activity sync."""
import uuid
from datetime import datetime, timezone
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
settings = get_settings()
POLAR_AUTH_URL = "https://flow.polar.com/oauth2/authorization"
POLAR_TOKEN_URL = "https://polarremote.com/v2/oauth2/token"
POLAR_API = "https://www.polaraccesslink.com/v3"
def get_polar_auth_url(user_id: str) -> str:
return (
f"{POLAR_AUTH_URL}?response_type=code"
f"&client_id={settings.polar_client_id}"
f"&redirect_uri={settings.base_url}/api/integrations/polar/callback"
f"&state={user_id}"
)
async def exchange_polar_code(code: str) -> dict:
import base64
creds = base64.b64encode(f"{settings.polar_client_id}:{settings.polar_client_secret}".encode()).decode()
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(POLAR_TOKEN_URL, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": f"{settings.base_url}/api/integrations/polar/callback",
}, headers={
"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded",
})
resp.raise_for_status()
return resp.json()
async def register_polar_user(access_token: str) -> dict | None:
"""Register user with Polar AccessLink (required before pulling data)."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{POLAR_API}/users",
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
json={"member-id": "fitness-rewards-user"},
)
if resp.status_code in (200, 409): # 409 = already registered
return resp.json() if resp.status_code == 200 else {"status": "already_registered"}
return None
async def sync_polar_activities(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
"""Pull exercises from Polar AccessLink."""
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "polar")
)
token = result.scalar_one_or_none()
if not token:
return []
headers = {"Authorization": f"Bearer {token.access_token}", "Accept": "application/json"}
# List available exercises (transactional endpoint)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{POLAR_API}/users/exercise-transactions", headers=headers)
if resp.status_code != 200:
return []
tx_data = resp.json()
imported = []
for tx in tx_data.get("exercise-transactions", []):
tx_url = tx.get("resource-uri", "")
if not tx_url:
continue
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(tx_url, headers=headers)
if resp.status_code != 200:
continue
exercises = resp.json().get("exercises", [])
for ex in exercises:
ext_id = str(ex.get("id", ""))
if not ext_id:
continue
# Skip duplicates
existing = await db.execute(
select(ActivityLog).where(
ActivityLog.user_id == user_id,
ActivityLog.source == "polar",
ActivityLog.external_id == ext_id,
)
)
if existing.scalar_one_or_none():
continue
start = ex.get("start-time", "")
try:
activity_date = datetime.fromisoformat(start).date()
except (ValueError, TypeError):
from datetime import date
activity_date = date.today()
# Parse duration ISO 8601 (e.g., PT1H30M)
duration_str = ex.get("duration", "")
duration_min = None
if duration_str:
import re
match = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?", duration_str)
if match:
hours = int(match.group(1) or 0)
mins = int(match.group(2) or 0)
duration_min = hours * 60 + mins
entry = await log_activity_with_points(
db=db,
user_id=user_id,
category="workout",
activity_date=activity_date,
title=ex.get("sport", "Polar Exercise"),
duration_minutes=duration_min,
source="polar",
external_id=ext_id,
metadata={
"calories": ex.get("calories"),
"heart_rate_avg": ex.get("heart-rate", {}).get("average"),
"sport": ex.get("sport"),
"distance": ex.get("distance"),
},
)
imported.append({
"id": str(entry.id),
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": activity_date.isoformat(),
})
return imported

View file

@ -0,0 +1,67 @@
"""Resource recommendation engine — matches external programs to user profiles."""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.resource import Resource
from app.models.profile import UserProfile
async def get_recommendations(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
profile_result = await db.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = profile_result.scalar_one_or_none()
if not profile:
return []
query = select(Resource).where(Resource.is_active == True)
if profile.equipment_access == "none":
query = query.where(Resource.equipment_needed == "none")
elif profile.equipment_access == "basic_home":
query = query.where(Resource.equipment_needed.in_(["none", "basic_home"]))
result = await db.execute(query)
resources = result.scalars().all()
user_prefs = set(profile.activity_preferences or [])
user_goal = profile.primary_goal
user_stage = profile.ttm_stage
difficulty_map = {
"precontemplation": "beginner",
"contemplation": "beginner",
"preparation": "beginner",
"action": "intermediate",
"maintenance": "advanced",
}
scored = []
for r in resources:
score = 0
if user_goal and user_goal in (r.goal_tags or []):
score += 30
overlap = user_prefs & set(r.activity_tags or [])
score += len(overlap) * 15
if user_stage and user_stage in (r.ttm_stages or []):
score += 20
if r.difficulty == difficulty_map.get(user_stage):
score += 10
scored.append((r, score))
scored.sort(key=lambda x: x[1], reverse=True)
return [
{
"id": str(r.id),
"name": r.name,
"source": r.source,
"url": r.url,
"description": r.description,
"difficulty": r.difficulty,
"duration_days": r.duration_days,
"goal_tags": r.goal_tags,
"activity_tags": r.activity_tags,
"score": s,
}
for r, s in scored[:8]
]

View file

@ -0,0 +1,162 @@
"""Strava API client for activity sync."""
import uuid
from datetime import datetime, timezone
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
settings = get_settings()
STRAVA_AUTH_URL = "https://www.strava.com/oauth/authorize"
STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
STRAVA_API = "https://www.strava.com/api/v3"
# Map Strava activity types to our categories
STRAVA_TYPE_MAP = {
"Run": "workout",
"Ride": "workout",
"Swim": "workout",
"Walk": "workout",
"Hike": "workout",
"WeightTraining": "workout",
"Crossfit": "workout",
"Yoga": "workout",
"Workout": "workout",
}
def get_strava_auth_url(user_id: str) -> str:
"""Generate the Strava OAuth authorization URL."""
return (
f"{STRAVA_AUTH_URL}?client_id={settings.strava_client_id}"
f"&response_type=code"
f"&redirect_uri={settings.base_url}/api/integrations/strava/callback"
f"&scope=read,activity:read_all"
f"&approval_prompt=force"
f"&state={user_id}"
)
async def exchange_strava_code(code: str) -> dict:
"""Exchange authorization code for tokens."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(STRAVA_TOKEN_URL, data={
"client_id": settings.strava_client_id,
"client_secret": settings.strava_client_secret,
"code": code,
"grant_type": "authorization_code",
})
resp.raise_for_status()
return resp.json()
async def refresh_strava_token(token: OAuthToken, db: AsyncSession) -> OAuthToken:
"""Refresh an expired Strava access token."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(STRAVA_TOKEN_URL, data={
"client_id": settings.strava_client_id,
"client_secret": settings.strava_client_secret,
"refresh_token": token.refresh_token,
"grant_type": "refresh_token",
})
resp.raise_for_status()
data = resp.json()
token.access_token = data["access_token"]
token.refresh_token = data.get("refresh_token", token.refresh_token)
token.expires_at = datetime.fromtimestamp(data["expires_at"], tz=timezone.utc)
token.updated_at = datetime.now(timezone.utc)
await db.flush()
return token
async def get_valid_token(db: AsyncSession, user_id: uuid.UUID) -> OAuthToken | None:
"""Get a valid Strava token, refreshing if needed."""
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "strava")
)
token = result.scalar_one_or_none()
if not token:
return None
if token.expires_at and token.expires_at < datetime.now(timezone.utc):
token = await refresh_strava_token(token, db)
return token
async def sync_strava_activities(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
"""Pull new activities from Strava and award points."""
token = await get_valid_token(db, user_id)
if not token:
return []
# Find the most recent synced activity date to avoid duplicates
result = await db.execute(
select(ActivityLog.logged_at)
.where(ActivityLog.user_id == user_id, ActivityLog.source == "strava")
.order_by(ActivityLog.logged_at.desc())
.limit(1)
)
last_sync = result.scalar_one_or_none()
after_ts = int(last_sync.timestamp()) if last_sync else 0
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"{STRAVA_API}/athlete/activities",
params={"after": after_ts, "per_page": 30},
headers={"Authorization": f"Bearer {token.access_token}"},
)
if resp.status_code != 200:
return []
activities = resp.json()
imported = []
for act in activities:
ext_id = str(act["id"])
# Skip if already imported
existing = await db.execute(
select(ActivityLog).where(
ActivityLog.user_id == user_id,
ActivityLog.source == "strava",
ActivityLog.external_id == ext_id,
)
)
if existing.scalar_one_or_none():
continue
category = STRAVA_TYPE_MAP.get(act.get("type", ""), "workout")
start_local = act.get("start_date_local", act.get("start_date", ""))
activity_date = datetime.fromisoformat(start_local.replace("Z", "+00:00")).date()
entry = await log_activity_with_points(
db=db,
user_id=user_id,
category=category,
activity_date=activity_date,
title=act.get("name", "Strava Activity"),
duration_minutes=(act.get("elapsed_time", 0) // 60) or None,
source="strava",
external_id=ext_id,
metadata={
"distance_km": round(act.get("distance", 0) / 1000, 2),
"calories": act.get("calories"),
"average_heartrate": act.get("average_heartrate"),
"type": act.get("type"),
"moving_time": act.get("moving_time"),
},
)
imported.append({
"id": str(entry.id),
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": activity_date.isoformat(),
})
return imported