workout-tracker / tracker /training_service.py
ronheichman's picture
Deploy workout tracker
c5700c6 verified
Raw
History Blame Contribute Delete
15.6 kB
from __future__ import annotations
from datetime import datetime, timedelta
from sqlmodel import Session, select
from tracker.active_program import resolve_active_program
from tracker.config import TrackerConfig
from tracker.models import (
BodyWeightLog,
Exercise,
Program,
ProgramExercise,
RunLog,
WorkoutLog,
)
from tracker.progression import suggest_next
from tracker.training_schemas import (
AddProgramExerciseInput,
BodyWeightEntry,
CreateProgramInput,
ExerciseResponse,
ExerciseWithSuggestion,
HistoryResponse,
LogRunInput,
LogWorkoutInput,
ProgramDetailResponse,
ProgramExerciseDetail,
ProgramSummary,
RunLogEntry,
TodayResponse,
UpdateProgramExerciseInput,
WeightInput,
WorkoutSessionSummary,
)
from tracker.workout_completion import find_last_completed_workout
from tracker.workout_day_text import build_program_day_summary_text
def latest_body_weight_kg(session: Session) -> float | None:
row = session.exec(
select(BodyWeightLog).order_by(BodyWeightLog.logged_at.desc()).limit(1),
).first()
if row is None:
return None
return row.weight_kg
def fetch_today(
session: Session,
cfg: TrackerConfig,
day_label: str,
program_id: int | None,
) -> tuple[TodayResponse | None, str | None]:
if program_id is not None:
program = session.get(Program, program_id)
else:
program = resolve_active_program(session, cfg)
if program is None:
return None, "No program found"
program_exercises = session.exec(
select(ProgramExercise)
.where(ProgramExercise.program_id == program.program_id)
.where(ProgramExercise.day_label == day_label)
.order_by(ProgramExercise.order_index),
).all()
day_summary_text = build_program_day_summary_text(
list(program_exercises),
session,
tracker_base_url=cfg.tracker_app_url,
)
body_weight_kg = latest_body_weight_kg(session)
exercises_with_suggestions: list[ExerciseWithSuggestion] = []
for pe in program_exercises:
exercise = session.get(Exercise, pe.exercise_id)
if exercise is None:
continue
last_logs = session.exec(
select(WorkoutLog)
.where(WorkoutLog.exercise_id == pe.exercise_id)
.where(WorkoutLog.program_id == program.program_id)
.where(WorkoutLog.day_label == day_label)
.order_by(WorkoutLog.logged_at.desc())
.limit(pe.target_sets),
).all()
suggestion = suggest_next(
pe,
last_logs,
exercise,
body_weight_kg,
calibration_bench_lb_per_hand=cfg.calibration_dumbbell_bench_lb_per_hand,
calibration_squat_lb_per_hand=cfg.calibration_dumbbell_squat_lb_per_hand,
dumbbell_weight_quantum_lb=cfg.dumbbell_weight_quantum_lb,
)
exercises_with_suggestions.append(ExerciseWithSuggestion(
exercise_id=exercise.exercise_id,
name=exercise.name,
muscle_group=exercise.muscle_group,
equipment=exercise.equipment,
target_sets=pe.target_sets,
target_rep_min=pe.target_rep_min,
target_rep_max=pe.target_rep_max,
rest_seconds=pe.rest_seconds,
superset_group=pe.superset_group,
weight_increment_kg=pe.weight_increment_kg,
suggestion=suggestion,
))
return TodayResponse(
program_name=program.name,
program_id=program.program_id,
day_label=day_label,
day_summary_text=day_summary_text,
exercises=exercises_with_suggestions,
), None
def log_workout_session(session: Session, body: LogWorkoutInput) -> int:
now = datetime.now()
logged_count = 0
for s in body.sets:
log_entry = WorkoutLog(
exercise_id=s.exercise_id,
program_id=body.program_id,
day_label=body.day_label,
logged_at=now,
set_number=s.set_number,
reps_target=s.reps_done,
reps_done=s.reps_done,
weight_kg=s.weight_kg,
completed=True,
)
session.add(log_entry)
logged_count += 1
session.commit()
return logged_count
def log_body_weight(session: Session, body: WeightInput) -> float:
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
existing = session.exec(
select(BodyWeightLog)
.where(BodyWeightLog.logged_at >= today_start)
.where(BodyWeightLog.logged_at < today_end),
).first()
if existing:
existing.weight_kg = body.weight_kg
session.add(existing)
else:
session.add(BodyWeightLog(weight_kg=body.weight_kg))
session.commit()
return body.weight_kg
def run_log_to_entry(session: Session, row: RunLog) -> RunLogEntry:
program_name = ""
if row.program_id is not None:
program = session.get(Program, row.program_id)
program_name = program.name if program else ""
run_log_id = row.run_log_id
if run_log_id is None:
msg = "run_log_id missing after load"
raise ValueError(msg)
return RunLogEntry(
run_log_id=run_log_id,
logged_at=row.logged_at,
program_id=row.program_id,
day_label=row.day_label,
program_name=program_name,
distance_km=row.distance_km,
duration_minutes=row.duration_minutes,
notes=row.notes,
)
def log_running_session(session: Session, body: LogRunInput) -> tuple[int | None, str | None]:
program = session.get(Program, body.program_id)
if program is None:
return None, "Program not found"
row = RunLog(
program_id=body.program_id,
day_label=body.day_label,
distance_km=body.distance_km,
duration_minutes=body.duration_minutes,
notes=body.notes,
)
session.add(row)
session.commit()
session.refresh(row)
run_log_id = row.run_log_id
if run_log_id is None:
return None, "Run log insert failed"
return run_log_id, None
def fetch_runs(
session: Session,
days: int,
program_id: int | None,
day_label: str | None,
) -> list[RunLogEntry]:
cutoff = datetime.now() - timedelta(days=days)
statement = select(RunLog).where(RunLog.logged_at >= cutoff)
if program_id is not None:
statement = statement.where(RunLog.program_id == program_id)
if day_label is not None:
statement = statement.where(RunLog.day_label == day_label)
statement = statement.order_by(RunLog.logged_at.desc())
rows = session.exec(statement).all()
return [
run_log_to_entry(session, r)
for r in rows
if r.run_log_id is not None
]
def fetch_history(session: Session, days: int) -> HistoryResponse:
cutoff = datetime.now() - timedelta(days=days)
weights = session.exec(
select(BodyWeightLog)
.where(BodyWeightLog.logged_at >= cutoff)
.order_by(BodyWeightLog.logged_at),
).all()
logs = session.exec(
select(WorkoutLog)
.where(WorkoutLog.logged_at >= cutoff)
.order_by(WorkoutLog.logged_at),
).all()
sessions_map: dict[str, list[WorkoutLog]] = {}
for log in logs:
date_key = log.logged_at.strftime("%Y-%m-%d")
day_key = f"{date_key}:{log.program_id}:{log.day_label}"
sessions_map.setdefault(day_key, []).append(log)
workout_sessions: list[WorkoutSessionSummary] = []
for group_logs in sessions_map.values():
first = group_logs[0]
program = session.get(Program, first.program_id)
exercise_ids = {log.exercise_id for log in group_logs}
workout_sessions.append(WorkoutSessionSummary(
logged_at=first.logged_at,
program_name=program.name if program else "Unknown",
day_label=first.day_label,
exercise_count=len(exercise_ids),
total_sets=len(group_logs),
))
run_rows = session.exec(
select(RunLog)
.where(RunLog.logged_at >= cutoff)
.order_by(RunLog.logged_at.desc()),
).all()
runs_list = [
run_log_to_entry(session, r)
for r in run_rows
if r.run_log_id is not None
]
return HistoryResponse(
body_weights=[
BodyWeightEntry(logged_at=w.logged_at, weight_kg=w.weight_kg)
for w in weights
],
workout_sessions=sorted(workout_sessions, key=lambda s: s.logged_at),
runs=runs_list,
)
def list_program_summaries(session: Session) -> list[ProgramSummary]:
programs = session.exec(select(Program)).all()
result: list[ProgramSummary] = []
for p in programs:
pe_list = session.exec(
select(ProgramExercise)
.where(ProgramExercise.program_id == p.program_id),
).all()
day_labels = {pe.day_label for pe in pe_list}
result.append(ProgramSummary(
program_id=p.program_id,
name=p.name,
description=p.description,
day_count=len(day_labels),
exercise_count=len(pe_list),
))
return result
def create_program_from_input(session: Session, body: CreateProgramInput) -> ProgramSummary:
program = Program(name=body.name, description=body.description)
session.add(program)
session.flush()
total_exercises = 0
for day in body.days:
for ex in day.exercises:
session.add(ProgramExercise(
program_id=program.program_id,
day_label=day.day_label,
exercise_id=ex.exercise_id,
order_index=ex.order_index,
target_sets=ex.target_sets,
target_rep_min=ex.target_rep_min,
target_rep_max=ex.target_rep_max,
weight_increment_kg=ex.weight_increment_kg,
rest_seconds=ex.rest_seconds,
superset_group=ex.superset_group,
starting_weight_kg=ex.starting_weight_kg,
))
total_exercises += 1
session.commit()
return ProgramSummary(
program_id=program.program_id,
name=program.name,
description=program.description,
day_count=len(body.days),
exercise_count=total_exercises,
)
def fetch_active_program_detail(
session: Session,
cfg: TrackerConfig,
) -> tuple[ProgramDetailResponse | None, str | None]:
program = resolve_active_program(session, cfg)
if program is None:
return None, "No program found"
pe_list = session.exec(
select(ProgramExercise)
.where(ProgramExercise.program_id == program.program_id),
).all()
day_labels = sorted({pe.day_label for pe in pe_list})
last_day, last_date = find_last_completed_workout(session, program.program_id)
return ProgramDetailResponse(
program_id=program.program_id,
name=program.name,
description=program.description,
day_labels=day_labels,
last_workout_day=last_day,
last_workout_date=last_date,
), None
def list_exercise_catalog(session: Session) -> list[ExerciseResponse]:
exercises = session.exec(select(Exercise).order_by(Exercise.name)).all()
return [
ExerciseResponse(
exercise_id=ex.exercise_id,
name=ex.name,
muscle_group=ex.muscle_group,
equipment=ex.equipment,
)
for ex in exercises
]
def add_exercise_to_program(
session: Session,
program_id: int,
body: AddProgramExerciseInput,
) -> tuple[ProgramExerciseDetail | None, str | None]:
program = session.get(Program, program_id)
if program is None:
return None, f"Program {program_id} not found"
exercise = session.get(Exercise, body.exercise_id)
if exercise is None:
return None, f"Exercise {body.exercise_id} not found"
pe = ProgramExercise(
program_id=program_id,
day_label=body.day_label,
exercise_id=body.exercise_id,
order_index=body.order_index,
target_sets=body.target_sets,
target_rep_min=body.target_rep_min,
target_rep_max=body.target_rep_max,
weight_increment_kg=body.weight_increment_kg,
rest_seconds=body.rest_seconds,
superset_group=body.superset_group,
starting_weight_kg=body.starting_weight_kg,
)
session.add(pe)
session.commit()
return ProgramExerciseDetail(
exercise_id=body.exercise_id,
exercise_name=exercise.name,
day_label=body.day_label,
order_index=body.order_index,
target_sets=body.target_sets,
target_rep_min=body.target_rep_min,
target_rep_max=body.target_rep_max,
weight_increment_kg=body.weight_increment_kg,
rest_seconds=body.rest_seconds,
superset_group=body.superset_group,
starting_weight_kg=body.starting_weight_kg,
), None
def remove_program_exercise(
session: Session,
program_id: int,
exercise_id: int,
day_label: str,
) -> str | None:
pe = session.exec(
select(ProgramExercise)
.where(ProgramExercise.program_id == program_id)
.where(ProgramExercise.exercise_id == exercise_id)
.where(ProgramExercise.day_label == day_label),
).first()
if pe is None:
return (
f"Exercise {exercise_id} not found in program {program_id} day {day_label}"
)
session.delete(pe)
session.commit()
return None
def update_program_exercise(
session: Session,
program_id: int,
exercise_id: int,
day_label: str,
body: UpdateProgramExerciseInput,
) -> tuple[ProgramExerciseDetail | None, str | None]:
pe = session.exec(
select(ProgramExercise)
.where(ProgramExercise.program_id == program_id)
.where(ProgramExercise.exercise_id == exercise_id)
.where(ProgramExercise.day_label == day_label),
).first()
if pe is None:
return None, (
f"Exercise {exercise_id} not found in program {program_id} day {day_label}"
)
if body.target_sets is not None:
pe.target_sets = body.target_sets
if body.target_rep_min is not None:
pe.target_rep_min = body.target_rep_min
if body.target_rep_max is not None:
pe.target_rep_max = body.target_rep_max
if body.weight_increment_kg is not None:
pe.weight_increment_kg = body.weight_increment_kg
if body.rest_seconds is not None:
pe.rest_seconds = body.rest_seconds
if body.order_index is not None:
pe.order_index = body.order_index
if body.superset_group is not None:
pe.superset_group = body.superset_group
if body.starting_weight_kg is not None:
pe.starting_weight_kg = body.starting_weight_kg
session.add(pe)
session.commit()
exercise = session.get(Exercise, exercise_id)
return ProgramExerciseDetail(
exercise_id=exercise_id,
exercise_name=exercise.name if exercise else "Unknown",
day_label=day_label,
order_index=pe.order_index,
target_sets=pe.target_sets,
target_rep_min=pe.target_rep_min,
target_rep_max=pe.target_rep_max,
weight_increment_kg=pe.weight_increment_kg,
rest_seconds=pe.rest_seconds,
superset_group=pe.superset_group,
starting_weight_kg=pe.starting_weight_kg,
), None