All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13s
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>
175 lines
5.8 KiB
Python
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,
|
|
})
|