feat: add Phase 5 Progression & Analytics — smart suggestions, dashboard, schedule

Add auto-progression engine (ProgressionService) with rep increase, weight
increase, deload, and felt-easy acceleration rules. Add AnalyticsService for
user stats, exercise progress charts, and volume-by-day data. New dashboard
and schedule routes with Chart.js visualizations. Progression badges shown
inline on workout day view. Navigation updated with Dashboard and Schedule links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:26:23 -06:00
parent e35b78ae87
commit 134542b66f
19 changed files with 1209 additions and 0 deletions

View File

@@ -30,6 +30,8 @@ from app.routes.pages import router as pages_router
from app.routes.profiles import router as profiles_router
from app.routes.logging import router as logging_router
from app.routes.workouts import router as workouts_router
from app.routes.dashboard import router as dashboard_router
from app.routes.schedule import router as schedule_router
from app.services.seed_service import SeedService
from app.services.auth_service import AuthService
from app.services.user_service import UserService
@@ -111,6 +113,8 @@ def create_app() -> FastAPI:
app.include_router(pages_router)
app.include_router(profiles_router)
app.include_router(workouts_router)
app.include_router(dashboard_router)
app.include_router(schedule_router)
# Database setup
engine = get_engine(settings.database_url)

102
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,102 @@
"""Progress dashboard routes.
Displays summary statistics, volume charts, and per-exercise progress.
"""
import json
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.analytics_service import AnalyticsService
from app.services.exercise_service import ExerciseService
from app.services.progression_service import ProgressionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("", response_class=HTMLResponse)
async def dashboard(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render the progress dashboard for the active profile.
Shows: summary stats, volume by day chart, exercise progress links.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
stats = {}
volume_data = {}
if active_profile_id:
analytics = AnalyticsService(session)
stats = analytics.get_user_stats(active_profile_id)
volume_data = analytics.get_volume_by_day(active_profile_id)
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
templates = request.app.state.templates
return templates.TemplateResponse("pages/dashboard.html", {
"request": request,
"stats": stats,
"volume_data_json": json.dumps(volume_data),
"exercises": exercises,
"active_profile": active_profile,
"admin": admin,
})
@router.get("/exercise/{exercise_id}", response_class=HTMLResponse)
async def exercise_progress(
exercise_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render per-exercise progress page with charts and suggestions."""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
exercise_service = ExerciseService(session)
exercise = exercise_service.get_exercise_by_id(exercise_id)
progress_data = {}
suggestion = {}
if active_profile_id:
analytics = AnalyticsService(session)
progress_data = analytics.get_exercise_progress(
active_profile_id, exercise_id,
)
progression = ProgressionService(session)
suggestion = progression.get_suggestion(
active_profile_id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("pages/exercise_progress.html", {
"request": request,
"exercise": exercise,
"progress_data_json": json.dumps(progress_data),
"suggestion": suggestion,
"active_profile": active_profile,
"admin": admin,
})

86
app/routes/schedule.py Normal file
View File

@@ -0,0 +1,86 @@
"""4-week schedule calendar routes.
Displays a calendar view showing which workout day maps to which date.
"""
from datetime import date, timedelta
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.exercise_service import ExerciseService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/schedule", tags=["schedule"])
@router.get("", response_class=HTMLResponse)
async def schedule_view(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render the 4-week schedule calendar.
Shows a 4-week grid where each training day is mapped to a
calendar date. Days with completed sessions are highlighted.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
exercise_service = ExerciseService(session)
workout_days = exercise_service.list_workout_days()
# Build 4-week calendar starting from Monday of current week
today = date.today()
monday = today - timedelta(days=today.weekday())
weeks = []
completed_dates = set()
# Get completed sessions for highlighting
if active_profile_id:
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(
user_id=active_profile_id, limit=100,
)
completed_dates = {ws.date for ws in sessions_list}
# 4 workout days per week, 4 weeks
for week_num in range(4):
week_start = monday + timedelta(weeks=week_num)
week_data = {
"week_number": week_num + 1,
"days": [],
}
for day_offset, workout_day in enumerate(workout_days):
training_date = week_start + timedelta(days=day_offset)
is_today = training_date == today
is_completed = training_date in completed_dates
week_data["days"].append({
"workout_day": workout_day,
"date": training_date,
"is_today": is_today,
"is_completed": is_completed,
})
weeks.append(week_data)
templates = request.app.state.templates
return templates.TemplateResponse("pages/schedule.html", {
"request": request,
"weeks": weeks,
"active_profile": active_profile,
"admin": admin,
})

View File

@@ -16,6 +16,7 @@ from app.models.user import User
from app.models.user_exercise_program import UserExerciseProgram
from app.services.exercise_service import ExerciseService
from app.services.log_service import LogService
from app.services.progression_service import ProgressionService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
@@ -99,6 +100,15 @@ async def workout_day_detail(
workout_day_id = d.id
break
# Get progression suggestions for each exercise
suggestions = {}
if active_profile_id:
progression = ProgressionService(session)
for exercise in exercises:
suggestions[exercise.id] = progression.get_suggestion(
active_profile_id, exercise.id,
)
# Load existing logs for today's session (if any)
if active_profile_id and workout_day_id:
ws_service = WorkoutSessionService(session)
@@ -121,6 +131,7 @@ async def workout_day_detail(
"programs": programs,
"active_profile": active_profile,
"existing_logs": existing_logs,
"suggestions": suggestions,
"workout_day_id": workout_day_id,
"admin": admin,
})

View File

@@ -0,0 +1,173 @@
"""Analytics service for progress dashboard and chart data.
Aggregates workout log data into stats, trends, and chart-ready formats.
"""
import re
from datetime import date, timedelta
import structlog
from sqlmodel import Session, select
from app.models.workout_day import WorkoutDay
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
def _weight_to_float(weight_str: str) -> float:
"""Convert weight string to float for volume calculations.
Args:
weight_str: Weight like '30 lbs' or 'BW'.
Returns:
Numeric weight, or 0.0 for bodyweight.
"""
if not weight_str or weight_str.upper() == "BW":
return 0.0
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
return float(match.group(1)) if match else 0.0
class AnalyticsService:
"""Aggregates workout data for dashboards and charts.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def get_user_stats(self, user_id: int) -> dict:
"""Get summary statistics for a user.
Returns:
Dict with keys: total_sessions, total_volume, total_sets,
current_streak, last_workout_date.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
).all()
total_sessions = len(sessions)
total_volume = 0.0
total_sets = 0
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
for log_entry in logs:
total_sets += 1
weight = _weight_to_float(log_entry.weight_used)
total_volume += log_entry.reps_completed * weight
current_streak = 0
if sessions:
week_start = date.today() - timedelta(days=date.today().weekday())
for week_offset in range(52):
week_check = week_start - timedelta(weeks=week_offset)
week_end = week_check + timedelta(days=6)
has_session = any(
week_check <= ws.date <= week_end for ws in sessions
)
if has_session:
current_streak += 1
else:
break
last_workout = sessions[0].date if sessions else None
return {
"total_sessions": total_sessions,
"total_volume": round(total_volume),
"total_sets": total_sets,
"current_streak": current_streak,
"last_workout_date": last_workout,
}
def get_exercise_progress(
self, user_id: int, exercise_id: int,
) -> dict:
"""Get chart-ready progress data for a specific exercise.
Returns:
Dict with keys: dates, reps, weights, volumes.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.asc())
).all()
dates = []
reps = []
weights = []
volumes = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == ws.id,
WorkoutLog.exercise_id == exercise_id,
)
).all()
if not logs:
continue
avg_reps = sum(
log_entry.reps_completed for log_entry in logs
) / len(logs)
weight = _weight_to_float(logs[0].weight_used)
session_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
dates.append(ws.date.isoformat())
reps.append(round(avg_reps, 1))
weights.append(weight)
volumes.append(round(session_volume))
return {
"dates": dates,
"reps": reps,
"weights": weights,
"volumes": volumes,
}
def get_volume_by_day(self, user_id: int) -> dict:
"""Get total volume broken down by workout day.
Returns:
Dict mapping workout day name to total volume.
"""
days = self._session.exec(select(WorkoutDay)).all()
day_map = {d.id: d.name for d in days}
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
).all()
volume_by_day = {}
for ws in sessions:
day_name = day_map.get(ws.workout_day_id, "Unknown")
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
day_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
volume_by_day[day_name] = (
volume_by_day.get(day_name, 0) + round(day_volume)
)
return volume_by_day

View File

@@ -0,0 +1,271 @@
"""Auto-progression engine for workout programming.
Analyzes workout log history and applies the progression model:
- +1-2 reps/week until wk4 rep target
- +5 lbs every 2 weeks once at rep target
- Deload at week 5 (-20% weight, reset to wk1 reps)
- Accelerated weight increase when all sets felt easy
"""
import re
from datetime import date
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.progress_log import ProgressLog
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
def _parse_weight(weight_str: str) -> Optional[float]:
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
Args:
weight_str: Weight as a string.
Returns:
Numeric weight in lbs, or None for bodyweight.
"""
if not weight_str or weight_str.upper() == "BW":
return None
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
return float(match.group(1)) if match else None
def _format_weight(weight_lbs: Optional[float]) -> str:
"""Format a numeric weight back to a display string.
Args:
weight_lbs: Weight in lbs, or None for bodyweight.
Returns:
Formatted string like '35 lbs' or 'BW'.
"""
if weight_lbs is None:
return "BW"
if weight_lbs == int(weight_lbs):
return f"{int(weight_lbs)} lbs"
return f"{weight_lbs:.1f} lbs"
class ProgressionService:
"""Implements the auto-progression engine.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def _get_program(
self, user_id: int, exercise_id: int,
) -> Optional[UserExerciseProgram]:
"""Look up the user's program for a specific exercise."""
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == user_id,
UserExerciseProgram.exercise_id == exercise_id,
)
return self._session.exec(statement).first()
def _get_recent_sessions(
self, user_id: int, exercise_id: int, limit: int = 5,
) -> list[dict]:
"""Get recent session summaries for an exercise.
Returns a list of dicts with: date, avg_reps, weight, all_felt_easy.
"""
statement = (
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
.limit(limit * 2)
)
sessions = self._session.exec(statement).all()
results = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == ws.id,
WorkoutLog.exercise_id == exercise_id,
)
).all()
if not logs:
continue
avg_reps = sum(log.reps_completed for log in logs) / len(logs)
weight = logs[0].weight_used
all_felt_easy = all(log.felt_easy for log in logs)
results.append({
"date": ws.date,
"avg_reps": avg_reps,
"weight": weight,
"all_felt_easy": all_felt_easy,
"set_count": len(logs),
})
if len(results) >= limit:
break
return results
def get_suggestion(
self, user_id: int, exercise_id: int,
) -> dict:
"""Generate a progression suggestion for the next workout.
Analyzes recent log history against the user's program targets
and applies progression rules.
Returns:
Dict with keys: suggested_reps, suggested_weight,
progression_type, message.
"""
program = self._get_program(user_id, exercise_id)
if program is None:
return {
"suggested_reps": 0,
"suggested_weight": "",
"progression_type": "no_program",
"message": "No program found for this exercise.",
}
try:
wk1_reps = int(program.wk1_reps)
wk4_reps = int(program.wk4_reps)
except (ValueError, TypeError):
wk1_reps = 0
wk4_reps = 0
wk1_weight = program.wk1_weight
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
if not recent:
return {
"suggested_reps": wk1_reps,
"suggested_weight": wk1_weight,
"progression_type": "baseline",
"message": f"Start with {wk1_reps} reps @ {wk1_weight}.",
}
latest = recent[0]
current_reps = int(round(latest["avg_reps"]))
current_weight = latest["weight"]
current_weight_num = _parse_weight(current_weight)
consecutive_sessions = len(recent)
# Rule: Deload at week 5 (4 consecutive sessions completed)
if consecutive_sessions >= 4:
if current_weight_num is not None:
deload_weight = current_weight_num * 0.8
return {
"suggested_reps": wk1_reps,
"suggested_weight": _format_weight(deload_weight),
"progression_type": "deload",
"message": (
f"Deload week: {wk1_reps} reps @ "
f"{_format_weight(deload_weight)} (-20%)."
),
}
return {
"suggested_reps": wk1_reps,
"suggested_weight": current_weight,
"progression_type": "deload",
"message": f"Deload week: reset to {wk1_reps} reps.",
}
# Rule: Weight increase if at rep target and felt easy
if current_reps >= wk4_reps and latest["all_felt_easy"]:
if current_weight_num is not None:
new_weight = current_weight_num + 5
return {
"suggested_reps": wk1_reps,
"suggested_weight": _format_weight(new_weight),
"progression_type": "weight_increase",
"message": (
f"Weight up: {wk1_reps} reps @ "
f"{_format_weight(new_weight)} (+5 lbs)."
),
}
# Rule: Weight increase after 2 weeks at rep target
if (
current_reps >= wk4_reps
and len(recent) >= 2
and int(round(recent[1]["avg_reps"])) >= wk4_reps
):
if current_weight_num is not None:
new_weight = current_weight_num + 5
return {
"suggested_reps": wk1_reps,
"suggested_weight": _format_weight(new_weight),
"progression_type": "weight_increase",
"message": (
f"2 weeks at target: {wk1_reps} reps @ "
f"{_format_weight(new_weight)} (+5 lbs)."
),
}
# Rule: Rep increase (+1-2 reps)
if current_reps < wk4_reps:
increment = 2 if latest["all_felt_easy"] else 1
new_reps = min(current_reps + increment, wk4_reps)
return {
"suggested_reps": new_reps,
"suggested_weight": current_weight,
"progression_type": "reps_increase",
"message": (
f"Reps up: {new_reps} reps @ {current_weight} "
f"(+{increment})."
),
}
# Hold: at target, waiting for biweekly weight increase
return {
"suggested_reps": current_reps,
"suggested_weight": current_weight,
"progression_type": "hold",
"message": f"Hold at {current_reps} reps @ {current_weight}.",
}
def record_progression(
self,
user_id: int,
exercise_id: int,
suggested_reps: int,
suggested_weight: str,
actual_reps: int,
actual_weight: str,
progression_type: str,
) -> ProgressLog:
"""Record a progression entry in the progress_log table."""
progress_log = ProgressLog(
user_id=user_id,
exercise_id=exercise_id,
date=date.today(),
suggested_reps=suggested_reps,
suggested_weight=suggested_weight,
actual_reps=actual_reps,
actual_weight=actual_weight,
progression_applied=progression_type,
)
self._session.add(progress_log)
self._session.commit()
self._session.refresh(progress_log)
logger.info(
"progression_recorded",
user_id=user_id,
exercise_id=exercise_id,
type=progression_type,
)
return progress_log

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Dashboard -- SneakySwole{% endblock %}
{% block head_extra %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<hgroup>
<h1>Progress Dashboard</h1>
{% if active_profile %}
<p>{{ active_profile.display_name }}'s training overview</p>
{% else %}
<p>No profile selected -- <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
{% if stats %}
<!-- Summary Stats -->
<div class="grid">
{% include "partials/stats_card.html" %}
</div>
<!-- Volume by Day Chart -->
<article>
<header><h3>Volume by Workout Day</h3></header>
{% include "partials/volume_chart.html" %}
</article>
<!-- Exercise Progress Links -->
<article>
<header><h3>Per-Exercise Progress</h3></header>
<ul>
{% for exercise in exercises %}
<li>
<a href="/dashboard/exercise/{{ exercise.id }}">
{{ exercise.name }}
</a>
<small> -- {{ exercise.workout_day }} Day</small>
</li>
{% endfor %}
</ul>
</article>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ exercise.name }} Progress -- SneakySwole{% endblock %}
{% block head_extra %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<hgroup>
<h1>{{ exercise.name }}</h1>
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day</p>
</hgroup>
<!-- Progression Suggestion -->
{% if suggestion %}
<article>
<header><h3>Next Workout Suggestion</h3></header>
<p>{{ suggestion.message }}</p>
<div class="grid">
<div>
<small>Suggested Reps</small>
<p style="font-size:1.5rem; font-weight:700;">
{{ suggestion.suggested_reps }}
</p>
</div>
<div>
<small>Suggested Weight</small>
<p style="font-size:1.5rem; font-weight:700;">
{{ suggestion.suggested_weight }}
</p>
</div>
<div>
<small>Progression Type</small>
<p><mark>{{ suggestion.progression_type }}</mark></p>
</div>
</div>
</article>
{% endif %}
<!-- Progress Chart -->
<article>
<header><h3>Rep and Weight Trends</h3></header>
{% include "partials/progress_chart.html" %}
</article>
<a href="/dashboard" role="button" class="outline">Back to Dashboard</a>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}4-Week Schedule -- SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>4-Week Schedule</h1>
{% if active_profile %}
<p>Schedule for: <strong>{{ active_profile.display_name }}</strong></p>
{% else %}
<p>No profile selected -- <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
{% for week in weeks %}
<article>
<header>
<h3>Week {{ week.week_number }}</h3>
</header>
<div class="grid">
{% for day in week.days %}
<div style="text-align:center;
padding:1rem;
border-radius:0.5rem;
{% if day.is_today %}
border: 2px solid var(--pico-primary);
{% endif %}
{% if day.is_completed %}
background: rgba(99, 102, 241, 0.15);
{% endif %}">
<strong>{{ day.workout_day.name }}</strong>
<br>
<small>{{ day.date.strftime('%b %d') }}</small>
{% if day.is_completed %}
<br><mark>Done</mark>
{% endif %}
{% if day.is_today %}
<br><small><strong>Today</strong></small>
{% endif %}
<br>
<a href="/workouts/{{ day.workout_day.name|lower|replace(' ', '-') }}"
style="font-size:0.8rem;">
Start
</a>
</div>
{% endfor %}
</div>
</article>
{% endfor %}
{% endblock %}

View File

@@ -19,6 +19,11 @@
</div>
{% endif %}
{% if suggestions and suggestions[exercise.id] %}
{% set suggestion = suggestions[exercise.id] %}
{% include "partials/progression_badge.html" %}
{% endif %}
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>

View File

@@ -27,6 +27,8 @@
</details>
</li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/schedule">Schedule</a></li>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/history">History</a></li>
<li><a href="/exercises">Exercises</a></li>
<li><a href="/profiles">Profiles</a></li>

View File

@@ -0,0 +1,56 @@
<canvas id="progress-chart" style="max-height:300px;"></canvas>
<p id="progress-chart-empty" style="display:none;">No data yet.</p>
<script>
(function() {
var data = {{ progress_data_json|safe }};
if (!data.dates || data.dates.length === 0) {
document.getElementById('progress-chart').style.display = 'none';
document.getElementById('progress-chart-empty').style.display = 'block';
return;
}
new Chart(document.getElementById('progress-chart'), {
type: 'line',
data: {
labels: data.dates,
datasets: [
{
label: 'Avg Reps',
data: data.reps,
borderColor: 'rgba(99, 102, 241, 1)',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
yAxisID: 'y-reps',
tension: 0.3
},
{
label: 'Weight (lbs)',
data: data.weights,
borderColor: 'rgba(244, 63, 94, 1)',
backgroundColor: 'rgba(244, 63, 94, 0.2)',
yAxisID: 'y-weight',
tension: 0.3
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
'y-reps': {
type: 'linear', display: true, position: 'left',
title: { display: true, text: 'Reps', color: '#ccc' },
ticks: { color: '#ccc' }
},
'y-weight': {
type: 'linear', display: true, position: 'right',
title: { display: true, text: 'Weight (lbs)', color: '#ccc' },
ticks: { color: '#ccc' },
grid: { drawOnChartArea: false }
},
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>

View File

@@ -0,0 +1,11 @@
{% if suggestion and suggestion.progression_type != "no_program" %}
<div style="background: rgba(99, 102, 241, 0.1);
border-left: 3px solid var(--pico-primary);
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0 0.25rem 0.25rem 0;">
<small>
<strong>Suggestion:</strong> {{ suggestion.message }}
</small>
</div>
{% endif %}

View File

@@ -0,0 +1,24 @@
<article>
<header><h4>Sessions</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sessions }}
</p>
</article>
<article>
<header><h4>Total Volume</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ "{:,}".format(stats.total_volume) }} lbs
</p>
</article>
<article>
<header><h4>Total Sets</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sets }}
</p>
</article>
<article>
<header><h4>Streak</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
</p>
</article>

View File

@@ -0,0 +1,30 @@
<canvas id="volume-chart" style="max-height:300px;"></canvas>
<script>
(function() {
var data = {{ volume_data_json|safe }};
var labels = Object.keys(data);
var values = Object.values(data);
new Chart(document.getElementById('volume-chart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Total Volume (lbs)',
data: values,
backgroundColor: 'rgba(99, 102, 241, 0.7)',
borderColor: 'rgba(99, 102, 241, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { color: '#ccc' } },
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>

View File

@@ -0,0 +1,96 @@
"""Tests for the AnalyticsService class."""
from datetime import date, timedelta
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.exercise import Exercise
from app.models.workout_day import WorkoutDay
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.services.analytics_service import AnalyticsService
class TestAnalyticsService:
"""Tests for analytics data aggregation."""
def _setup(self):
"""Create DB with sessions and logs for analytics."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
)
session.add_all([user, day, exercise])
session.commit()
session.refresh(user)
session.refresh(day)
session.refresh(exercise)
# Create 3 sessions over 3 weeks
for week in range(3):
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=(2 - week) * 7),
)
session.add(ws)
session.commit()
session.refresh(ws)
for set_num in range(1, 4):
session.add(WorkoutLog(
session_id=ws.id, exercise_id=exercise.id,
set_number=set_num,
reps_completed=8 + week,
weight_used="30 lbs",
felt_easy=(week == 2),
))
session.commit()
service = AnalyticsService(session)
return session, service, user, exercise
def test_get_total_sessions(self) -> None:
"""Should return the total number of sessions for a user."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["total_sessions"] == 3
session.close()
def test_get_total_volume(self) -> None:
"""Should calculate total volume (sets x reps x weight)."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["total_volume"] > 0
session.close()
def test_get_workout_streak(self) -> None:
"""Should calculate consecutive workout weeks."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["current_streak"] >= 1
session.close()
def test_get_exercise_progress_data(self) -> None:
"""Should return chart-ready data for an exercise."""
session, service, user, exercise = self._setup()
data = service.get_exercise_progress(user.id, exercise.id)
assert "dates" in data
assert "reps" in data
assert "weights" in data
assert len(data["dates"]) == 3
session.close()
def test_get_volume_by_day(self) -> None:
"""Should return volume per workout day for charts."""
session, service, user, exercise = self._setup()
data = service.get_volume_by_day(user.id)
assert "Push" in data
assert data["Push"] > 0
session.close()

View File

@@ -0,0 +1,21 @@
"""Tests for dashboard routes."""
from fastapi.testclient import TestClient
class TestDashboard:
"""Tests for GET /dashboard."""
def test_dashboard_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard should require admin login."""
response = client.get("/dashboard", follow_redirects=False)
assert response.status_code in (401, 303)
class TestExerciseProgress:
"""Tests for GET /dashboard/exercise/<id>."""
def test_exercise_progress_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard/exercise/1 should require admin login."""
response = client.get("/dashboard/exercise/1", follow_redirects=False)
assert response.status_code in (401, 303)

View File

@@ -0,0 +1,161 @@
"""Tests for the ProgressionService class."""
from datetime import date, timedelta
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.exercise import Exercise
from app.models.workout_day import WorkoutDay
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.models.user_exercise_program import UserExerciseProgram
from app.models.progress_log import ProgressLog
from app.services.progression_service import ProgressionService
class TestProgressionService:
"""Tests for the auto-progression engine."""
def _setup(self):
"""Create an in-memory DB with a user, exercise, and program."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
)
session.add_all([user, day, exercise])
session.commit()
session.refresh(user)
session.refresh(day)
session.refresh(exercise)
program = UserExerciseProgram(
user_id=user.id, exercise_id=exercise.id,
wk1_reps="8", wk4_reps="12",
wk1_weight="30 lbs", wk4_weight="40 lbs",
)
session.add(program)
session.commit()
session.refresh(program)
service = ProgressionService(session)
return session, service, user, day, exercise, program
def test_suggest_reps_increase(self) -> None:
"""Should suggest +1-2 reps when below wk4 target."""
session, service, user, day, exercise, program = self._setup()
# Log a session where user did 8 reps (wk1 target)
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=7),
)
session.add(ws)
session.commit()
session.refresh(ws)
for set_num in range(1, 4):
session.add(WorkoutLog(
session_id=ws.id, exercise_id=exercise.id,
set_number=set_num, reps_completed=8,
weight_used="30 lbs", felt_easy=False,
))
session.commit()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
assert suggestion["suggested_reps"] >= 9 # +1-2 reps
assert suggestion["suggested_weight"] == "30 lbs" # same weight
assert suggestion["progression_type"] == "reps_increase"
session.close()
def test_suggest_weight_increase(self) -> None:
"""Should suggest +5 lbs when at wk4 rep target and felt easy."""
session, service, user, day, exercise, program = self._setup()
# Log two sessions at max reps, all felt easy
for week_offset in [14, 7]:
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=week_offset),
)
session.add(ws)
session.commit()
session.refresh(ws)
for set_num in range(1, 4):
session.add(WorkoutLog(
session_id=ws.id, exercise_id=exercise.id,
set_number=set_num, reps_completed=12,
weight_used="30 lbs", felt_easy=True,
))
session.commit()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
assert suggestion["suggested_weight"] == "35 lbs" # +5 lbs
assert suggestion["progression_type"] == "weight_increase"
session.close()
def test_suggest_deload(self) -> None:
"""Should suggest deload after 4 weeks of progression."""
session, service, user, day, exercise, program = self._setup()
# Log 4 weeks of sessions (simulate week 5 trigger)
for week in range(4):
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=(4 - week) * 7),
)
session.add(ws)
session.commit()
session.refresh(ws)
for set_num in range(1, 4):
session.add(WorkoutLog(
session_id=ws.id, exercise_id=exercise.id,
set_number=set_num, reps_completed=12,
weight_used="40 lbs", felt_easy=False,
))
session.commit()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
# After 4 consecutive weeks, week 5 should be deload
if suggestion["progression_type"] == "deload":
assert "32" in suggestion["suggested_weight"] # -20% of 40
session.close()
def test_no_suggestion_without_logs(self) -> None:
"""Should return program defaults when no logs exist."""
session, service, user, day, exercise, program = self._setup()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
assert suggestion["suggested_reps"] == 8 # wk1 default
assert suggestion["suggested_weight"] == "30 lbs" # wk1 default
assert suggestion["progression_type"] == "baseline"
session.close()
def test_record_progression(self) -> None:
"""record_progression should write to progress_log table."""
session, service, user, day, exercise, program = self._setup()
service.record_progression(
user_id=user.id,
exercise_id=exercise.id,
suggested_reps=10,
suggested_weight="30 lbs",
actual_reps=10,
actual_weight="30 lbs",
progression_type="reps_increase",
)
from sqlmodel import select
logs = session.exec(select(ProgressLog)).all()
assert len(logs) == 1
assert logs[0].progression_applied == "reps_increase"
session.close()

View File

@@ -0,0 +1,12 @@
"""Tests for schedule calendar routes."""
from fastapi.testclient import TestClient
class TestSchedule:
"""Tests for GET /schedule."""
def test_schedule_requires_auth(self, client: TestClient) -> None:
"""GET /schedule should require admin login."""
response = client.get("/schedule", follow_redirects=False)
assert response.status_code in (401, 303)