Files
SneakySwole/app/routes/dashboard.py
Phillip Tarrant df8d5c65fb
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13s
feat: enhance dashboard with PRs, adherence, activity, progression chart, and muscle heatmap
Add 3 new stat cards (Last Workout, Personal Records, Adherence Rate),
recent activity table, progression timeline chart, and muscle group
recency heatmap to the dashboard. Remove Total Volume card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:44:21 -05:00

175 lines
5.8 KiB
Python

"""Progress dashboard routes.
Displays summary statistics, volume charts, per-exercise progress,
and CSV export of workout history.
"""
import csv
import io
import json
import os
import re
import tempfile
from datetime import date, timedelta
from typing import Optional
import structlog
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, StreamingResponse
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.export_service import ExportService
from app.services.import_service import ImportService
from app.services.progression_service import ProgressionService
from app.utils.auth import require_active_profile
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),
profile: User = Depends(require_active_profile),
):
"""Render the progress dashboard for the active profile."""
analytics = AnalyticsService(session)
stats = analytics.get_user_stats(profile.id)
volume_data = analytics.get_volume_by_day(profile.id)
personal_records = analytics.get_personal_records(profile.id)
adherence = analytics.get_adherence_rate(profile.id)
progression_timeline = analytics.get_progression_timeline(profile.id)
muscle_recency = analytics.get_muscle_group_recency(profile.id)
recent_activity = analytics.get_recent_activity(profile.id)
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
today = date.today()
export_start = (today - timedelta(days=30)).isoformat()
export_end = today.isoformat()
templates = request.app.state.templates
return templates.TemplateResponse("pages/dashboard.html", {
"request": request,
"stats": stats,
"volume_data_json": json.dumps(volume_data),
"personal_records": personal_records,
"adherence": adherence,
"progression_timeline_json": json.dumps(progression_timeline),
"muscle_recency": muscle_recency,
"recent_activity": recent_activity,
"exercises": exercises,
"active_profile": profile,
"export_start_date": export_start,
"export_end_date": export_end,
})
@router.get("/export")
async def export_csv(
request: Request,
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Export workout history as a CSV file download."""
today = date.today()
default_start = today - timedelta(days=30)
try:
parsed_start = date.fromisoformat(start_date) if start_date else default_start
except ValueError:
parsed_start = default_start
try:
parsed_end = date.fromisoformat(end_date) if end_date else today
except ValueError:
parsed_end = today
export_service = ExportService(session)
rows = export_service.get_export_rows(profile.id, parsed_start, parsed_end)
output = io.StringIO()
headers = ["date", "workout_type", "exercise", "set_number", "reps", "weight", "felt_easy"]
writer = csv.DictWriter(output, fieldnames=headers)
writer.writeheader()
writer.writerows(rows)
safe_name = re.sub(r"[^a-z0-9_]", "", profile.display_name.lower().replace(" ", "_"))
filename = f"sneakyswole_{safe_name}_{parsed_start.isoformat()}_to_{parsed_end.isoformat()}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/import", response_class=HTMLResponse)
async def import_db(
request: Request,
db_file: UploadFile = File(...),
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Import workout history from an old SneakySwole database file."""
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db")
try:
with os.fdopen(tmp_fd, "wb") as tmp:
content = await db_file.read()
logger.info("import_upload_received", filename=db_file.filename, size=len(content))
tmp.write(content)
import_service = ImportService(session)
result = import_service.import_from_db(tmp_path)
except Exception:
logger.exception("import_failed")
raise
finally:
os.unlink(tmp_path)
templates = request.app.state.templates
return templates.TemplateResponse("partials/import_results.html", {
"request": request,
"result": result,
})
@router.get("/exercise/{exercise_id}", response_class=HTMLResponse)
async def exercise_progress(
exercise_id: int,
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Render per-exercise progress page with charts and suggestions."""
exercise_service = ExerciseService(session)
exercise = exercise_service.get_exercise_by_id(exercise_id)
analytics = AnalyticsService(session)
progress_data = analytics.get_exercise_progress(
profile.id, exercise_id,
)
progression = ProgressionService(session)
suggestion = progression.get_suggestion(
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": profile,
})