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,93 @@
from typing import Annotated
from datetime import date
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
router = APIRouter(prefix="/api/activities", tags=["activities"])
@router.get("")
async def list_activities(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
activity_date: date | None = Query(None, alias="date"),
):
query = select(ActivityLog).where(ActivityLog.user_id == user.id)
if activity_date:
query = query.where(ActivityLog.activity_date == activity_date)
query = query.order_by(ActivityLog.logged_at.desc()).limit(50)
result = await db.execute(query)
activities = result.scalars().all()
return [
{
"id": str(a.id),
"category": a.category,
"title": a.title,
"description": a.description,
"duration_minutes": a.duration_minutes,
"source": a.source,
"points_earned": a.points_earned,
"activity_date": a.activity_date.isoformat(),
"logged_at": a.logged_at.isoformat() if a.logged_at else None,
"program_day": a.program_day,
"metadata": a.metadata_json,
}
for a in activities
]
@router.post("")
async def create_activity(
req: ActivityCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
import uuid as uuid_mod
program_id = uuid_mod.UUID(req.program_id) if req.program_id else None
entry = await log_activity_with_points(
db=db,
user_id=user.id,
category=req.category,
activity_date=req.activity_date,
title=req.title,
description=req.description,
duration_minutes=req.duration_minutes,
program_id=program_id,
program_day=req.program_day,
metadata=req.metadata,
)
return {
"id": str(entry.id),
"category": entry.category,
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": entry.activity_date.isoformat(),
}
@router.delete("/{activity_id}")
async def delete_activity(
activity_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
import uuid as uuid_mod
aid = uuid_mod.UUID(activity_id)
result = await db.execute(
select(ActivityLog).where(ActivityLog.id == aid, ActivityLog.user_id == user.id)
)
entry = result.scalar_one_or_none()
if not entry:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Activity not found")
await db.delete(entry)
return {"status": "deleted"}

View file

@ -0,0 +1,65 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
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 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
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse)
async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)]):
existing = await db.execute(select(User).where(User.username == req.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
user = User(
username=req.username,
display_name=req.display_name,
password_hash=hash_password(req.password),
email=req.email,
)
db.add(user)
await db.flush()
# Create empty profile
profile = UserProfile(user_id=user.id)
db.add(profile)
# Create default point rules
for rule_data in DEFAULT_POINT_RULES:
db.add(PointRule(user_id=user.id, **rule_data))
# Create default daily targets
db.add(DailyTarget(user_id=user.id))
await db.flush()
token = create_access_token(str(user.id))
return TokenResponse(access_token=token)
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)]):
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(str(user.id))
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserResponse)
async def get_me(user: Annotated[User, Depends(get_current_user)]):
return UserResponse(
id=str(user.id),
username=user.username,
display_name=user.display_name,
email=user.email,
timezone=user.timezone,
)

111
backend/app/routers/food.py Normal file
View file

@ -0,0 +1,111 @@
from typing import Annotated
from datetime import date
from decimal import Decimal
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
router = APIRouter(prefix="/api/food", tags=["food"])
@router.get("")
async def list_food(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
food_date: date | None = Query(None, alias="date"),
):
query = select(FoodLog).where(FoodLog.user_id == user.id)
if food_date:
query = query.where(FoodLog.food_date == food_date)
query = query.order_by(FoodLog.logged_at.desc()).limit(100)
result = await db.execute(query)
items = result.scalars().all()
# Group by meal type
meals = {"breakfast": [], "lunch": [], "dinner": [], "snack": []}
total_cals = 0
for item in items:
entry = {
"id": str(item.id),
"food_name": item.food_name,
"brand": item.brand,
"serving_size": item.serving_size,
"servings": float(item.servings) if item.servings else 1,
"calories": float(item.calories) if item.calories else None,
"protein_g": float(item.protein_g) if item.protein_g else None,
"carbs_g": float(item.carbs_g) if item.carbs_g else None,
"fat_g": float(item.fat_g) if item.fat_g else None,
}
meals.get(item.meal_type, meals["snack"]).append(entry)
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)}
@router.post("")
async def log_food(
req: FoodCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
entry = FoodLog(
user_id=user.id,
meal_type=req.meal_type,
food_name=req.food_name,
brand=req.brand,
barcode=req.barcode,
serving_size=req.serving_size,
servings=Decimal(str(req.servings)),
calories=Decimal(str(req.calories)) if req.calories else None,
protein_g=Decimal(str(req.protein_g)) if req.protein_g else None,
carbs_g=Decimal(str(req.carbs_g)) if req.carbs_g else None,
fat_g=Decimal(str(req.fat_g)) if req.fat_g else None,
fiber_g=Decimal(str(req.fiber_g)) if req.fiber_g else None,
sodium_mg=Decimal(str(req.sodium_mg)) if req.sodium_mg else None,
sugar_g=Decimal(str(req.sugar_g)) if req.sugar_g else None,
food_date=req.food_date,
off_product_id=req.off_product_id,
off_data=req.off_data,
)
db.add(entry)
await db.flush()
return {"id": str(entry.id), "food_name": entry.food_name, "calories": float(entry.calories) if entry.calories else None}
@router.delete("/{food_id}")
async def delete_food(
food_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
import uuid
result = await db.execute(
select(FoodLog).where(FoodLog.id == uuid.UUID(food_id), FoodLog.user_id == user.id)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(status_code=404, detail="Food entry not found")
await db.delete(entry)
return {"status": "deleted"}
@router.get("/scan/{barcode}")
async def scan_barcode(barcode: str):
result = await lookup_barcode(barcode)
if not result:
raise HTTPException(status_code=404, detail="Product not found in Open Food Facts")
return result
@router.get("/search")
async def food_search(q: str = Query(..., min_length=2)):
results = await search_food(q)
return {"results": results}

View file

@ -0,0 +1,153 @@
from typing import Annotated
from datetime import datetime, timezone
import uuid as uuid_mod
from fastapi import APIRouter, Depends, HTTPException, Query
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 (
get_strava_auth_url, exchange_strava_code, sync_strava_activities,
)
from app.services.polar_sync import (
get_polar_auth_url, exchange_polar_code, register_polar_user, sync_polar_activities,
)
router = APIRouter(prefix="/api/integrations", tags=["integrations"])
@router.get("")
async def integration_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(OAuthToken).where(OAuthToken.user_id == user.id))
tokens = result.scalars().all()
providers = {t.provider: {"connected": True, "athlete_id": t.athlete_id} for t in tokens}
return {
"strava": providers.get("strava", {"connected": False}),
"polar": providers.get("polar", {"connected": False}),
}
# --- Strava ---
@router.get("/strava/auth")
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
return {"auth_url": get_strava_auth_url(str(user.id))}
@router.get("/strava/callback")
async def strava_callback(
code: str = Query(...),
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
user_id = uuid_mod.UUID(state)
data = await exchange_strava_code(code)
# Upsert token
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "strava")
)
token = result.scalar_one_or_none()
if token:
token.access_token = data["access_token"]
token.refresh_token = data["refresh_token"]
token.expires_at = datetime.fromtimestamp(data["expires_at"], tz=timezone.utc)
token.athlete_id = str(data.get("athlete", {}).get("id", ""))
token.updated_at = datetime.now(timezone.utc)
else:
token = OAuthToken(
user_id=user_id,
provider="strava",
access_token=data["access_token"],
refresh_token=data["refresh_token"],
expires_at=datetime.fromtimestamp(data["expires_at"], tz=timezone.utc),
athlete_id=str(data.get("athlete", {}).get("id", "")),
scope=data.get("scope", ""),
)
db.add(token)
await db.flush()
from app.config import get_settings
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?strava=connected")
@router.post("/strava/sync")
async def strava_sync(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
imported = await sync_strava_activities(db, user.id)
return {"imported": len(imported), "activities": imported}
# --- Polar ---
@router.get("/polar/auth")
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
return {"auth_url": get_polar_auth_url(str(user.id))}
@router.get("/polar/callback")
async def polar_callback(
code: str = Query(...),
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
user_id = uuid_mod.UUID(state)
data = await exchange_polar_code(code)
# Register user with Polar
await register_polar_user(data["access_token"])
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "polar")
)
token = result.scalar_one_or_none()
if token:
token.access_token = data["access_token"]
token.athlete_id = str(data.get("x_user_id", ""))
token.updated_at = datetime.now(timezone.utc)
else:
token = OAuthToken(
user_id=user_id,
provider="polar",
access_token=data["access_token"],
athlete_id=str(data.get("x_user_id", "")),
)
db.add(token)
await db.flush()
from app.config import get_settings
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?polar=connected")
@router.post("/polar/sync")
async def polar_sync(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
imported = await sync_polar_activities(db, user.id)
return {"imported": len(imported), "activities": imported}
# --- Disconnect ---
@router.delete("/{provider}")
async def disconnect(
provider: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if provider not in ("strava", "polar"):
raise HTTPException(status_code=400, detail="Invalid provider")
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user.id, OAuthToken.provider == provider)
)
token = result.scalar_one_or_none()
if token:
await db.delete(token)
return {"status": "disconnected", "provider": provider}

View file

@ -0,0 +1,139 @@
from typing import Annotated
from decimal import Decimal
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
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
@router.get("/status", response_model=OnboardingStatusResponse)
async def onboarding_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
profile = result.scalar_one_or_none()
if not profile:
return OnboardingStatusResponse(phase1_completed=False, phase2_completed=False)
return OnboardingStatusResponse(
phase1_completed=profile.phase1_completed,
phase2_completed=profile.phase2_completed,
primary_goal=profile.primary_goal,
ttm_stage=profile.ttm_stage,
)
@router.post("/phase1")
async def save_phase1(
req: Phase1Request,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
profile = result.scalar_one_or_none()
if not profile:
profile = UserProfile(user_id=user.id)
db.add(profile)
profile.primary_goal = req.primary_goal
profile.ttm_stage = req.ttm_stage
profile.phase1_completed = True
# Adjust daily targets based on TTM stage
targets = TTM_DAILY_TARGETS.get(req.ttm_stage, {})
if targets:
dt_result = await db.execute(select(DailyTarget).where(DailyTarget.user_id == user.id))
dt = dt_result.scalar_one_or_none()
if dt:
dt.daily_minimum_pts = targets.get("daily_minimum_pts", dt.daily_minimum_pts)
dt.weekly_target_pts = targets.get("weekly_target_pts", dt.weekly_target_pts)
await db.flush()
return {"status": "ok", "phase1_completed": True}
@router.post("/phase2")
async def save_phase2(
req: Phase2Request,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
profile = result.scalar_one_or_none()
if not profile:
profile = UserProfile(user_id=user.id)
db.add(profile)
# Body metrics
profile.age = req.age
if req.height_cm is not None:
profile.height_cm = Decimal(str(req.height_cm))
if req.weight_kg is not None:
profile.weight_kg = Decimal(str(req.weight_kg))
profile.gender = req.gender
# PAR-Q+
profile.parq_heart_condition = req.parq_heart_condition
profile.parq_joint_issues = req.parq_joint_issues
profile.parq_medications = req.parq_medications
profile.parq_other_conditions = req.parq_other_conditions
profile.parq_cleared = not (req.parq_heart_condition or req.parq_joint_issues or req.parq_medications)
# BREQ-2 motivation
if req.motivation_external is not None:
profile.motivation_external = Decimal(str(req.motivation_external))
if req.motivation_introjected is not None:
profile.motivation_introjected = Decimal(str(req.motivation_introjected))
if req.motivation_identified is not None:
profile.motivation_identified = Decimal(str(req.motivation_identified))
if req.motivation_intrinsic is not None:
profile.motivation_intrinsic = Decimal(str(req.motivation_intrinsic))
if req.motivation_amotivation is not None:
profile.motivation_amotivation = Decimal(str(req.motivation_amotivation))
# Calculate RAI
if all(v is not None for v in [
req.motivation_amotivation, req.motivation_external,
req.motivation_introjected, req.motivation_identified, req.motivation_intrinsic,
]):
rai = (
-3 * req.motivation_amotivation
+ -2 * req.motivation_external
+ -1 * req.motivation_introjected
+ 2 * req.motivation_identified
+ 3 * req.motivation_intrinsic
)
profile.motivation_rai = Decimal(str(round(rai, 1)))
# Preferences
profile.activity_preferences = req.activity_preferences
profile.equipment_access = req.equipment_access
profile.days_per_week = req.days_per_week
profile.minutes_per_session = req.minutes_per_session
profile.phase2_completed = True
await db.flush()
parq_warning = None
if not profile.parq_cleared:
parq_warning = "We recommend checking with your doctor before starting a new exercise program."
return {"status": "ok", "phase2_completed": True, "parq_warning": parq_warning}
@router.get("/recommendations")
async def get_resource_recommendations(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
recs = await get_recommendations(db, user.id)
return {"recommendations": recs}

View file

@ -0,0 +1,147 @@
from typing import Annotated
from datetime import date
import uuid as uuid_mod
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
router = APIRouter(prefix="/api/points", tags=["points"])
@router.get("/today")
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)
@router.get("/week")
async def week_summary(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
start: date | None = None,
):
d = start or date.today()
return await get_weekly_summary(db, user.id, d)
@router.get("/rules")
async def list_rules(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(PointRule).where(PointRule.user_id == user.id))
rules = result.scalars().all()
return [
{"id": str(r.id), "category": r.category, "description": r.description,
"points": r.points, "unit": r.unit, "is_active": r.is_active}
for r in rules
]
@router.patch("/rules/{rule_id}")
async def update_rule(
rule_id: str,
req: PointRuleUpdate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(PointRule).where(PointRule.id == uuid_mod.UUID(rule_id), PointRule.user_id == user.id)
)
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404)
if req.points is not None:
rule.points = req.points
if req.description is not None:
rule.description = req.description
if req.is_active is not None:
rule.is_active = req.is_active
await db.flush()
return {"status": "updated"}
@router.get("/targets")
async def get_targets(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(DailyTarget).where(DailyTarget.user_id == user.id))
t = result.scalar_one_or_none()
if not t:
return {"daily_minimum_pts": 80, "weekly_target_pts": 500, "weekly_bonus_pts": 50}
return {"daily_minimum_pts": t.daily_minimum_pts, "weekly_target_pts": t.weekly_target_pts, "weekly_bonus_pts": t.weekly_bonus_pts}
@router.patch("/targets")
async def update_targets(
req: DailyTargetUpdate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(DailyTarget).where(DailyTarget.user_id == user.id))
t = result.scalar_one_or_none()
if not t:
raise HTTPException(status_code=404)
if req.daily_minimum_pts is not None:
t.daily_minimum_pts = req.daily_minimum_pts
if req.weekly_target_pts is not None:
t.weekly_target_pts = req.weekly_target_pts
if req.weekly_bonus_pts is not None:
t.weekly_bonus_pts = req.weekly_bonus_pts
await db.flush()
return {"status": "updated"}
# --- Rewards ---
rewards_router = APIRouter(prefix="/api/rewards", tags=["rewards"])
@rewards_router.get("")
async def list_rewards(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(Reward).where(Reward.user_id == user.id).order_by(Reward.created_at))
rewards = result.scalars().all()
return [
{"id": str(r.id), "name": r.name, "description": r.description,
"point_cost": r.point_cost, "is_active": r.is_active}
for r in rewards
]
@rewards_router.post("")
async def create_reward(
req: RewardCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
reward = Reward(user_id=user.id, name=req.name, description=req.description, point_cost=req.point_cost)
db.add(reward)
await db.flush()
return {"id": str(reward.id), "name": reward.name, "point_cost": reward.point_cost}
@rewards_router.post("/{reward_id}/redeem")
async def redeem(
reward_id: str,
req: RewardRedeemRequest,
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.date)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["reason"])
return result

View file

@ -0,0 +1,116 @@
from typing import Annotated
from datetime import date, timedelta
import uuid as uuid_mod
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 pydantic import BaseModel
router = APIRouter(prefix="/api/programs", tags=["programs"])
class ProgramCreate(BaseModel):
name: str
source: str | None = None
source_url: str | None = None
start_date: date
duration_days: int = 90
notes: str | None = None
@router.get("")
async def list_programs(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(Program).where(Program.user_id == user.id).order_by(Program.created_at.desc())
)
programs = result.scalars().all()
out = []
for p in programs:
today = date.today()
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1
out.append({
"id": str(p.id), "name": p.name, "source": p.source, "source_url": p.source_url,
"start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat(),
"status": p.status, "current_day": min(day_num, total), "total_days": total,
})
return out
@router.post("")
async def create_program(
req: ProgramCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
program = Program(
user_id=user.id,
name=req.name,
source=req.source,
source_url=req.source_url,
start_date=req.start_date,
end_date=req.start_date + timedelta(days=req.duration_days - 1),
notes=req.notes,
)
db.add(program)
await db.flush()
return {"id": str(program.id), "name": program.name, "start_date": program.start_date.isoformat(), "end_date": program.end_date.isoformat()}
@router.get("/{program_id}")
async def get_program(
program_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(Program).where(Program.id == uuid_mod.UUID(program_id), Program.user_id == user.id)
)
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404)
# Count logged days
days_logged = await db.execute(
select(func.count(func.distinct(ActivityLog.activity_date)))
.where(ActivityLog.program_id == p.id)
)
logged = days_logged.scalar() or 0
today = date.today()
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1
return {
"id": str(p.id), "name": p.name, "source": p.source, "source_url": p.source_url,
"start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat(),
"status": p.status, "current_day": min(day_num, total), "total_days": total,
"days_logged": logged, "notes": p.notes,
}
@router.patch("/{program_id}")
async def update_program_status(
program_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
status: str = "completed",
):
result = await db.execute(
select(Program).where(Program.id == uuid_mod.UUID(program_id), Program.user_id == user.id)
)
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404)
p.status = status
await db.flush()
return {"status": p.status}