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