feat: add CSV export of workout history to dashboard

Add date range picker and download button to the dashboard page,
backed by GET /dashboard/export endpoint that returns a StreamingResponse
CSV file. Completes Phase 4 (final V2 improvement).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 14:22:50 -05:00
parent 0389aef56e
commit ebecfd0b58
5 changed files with 160 additions and 119 deletions

View File

@@ -1,19 +1,26 @@
"""Progress dashboard routes.
Displays summary statistics, volume charts, and per-exercise progress.
Displays summary statistics, volume charts, per-exercise progress,
and CSV export of workout history.
"""
import csv
import io
import json
import re
from datetime import date, timedelta
from typing import Optional
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, Query, Request
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.progression_service import ProgressionService
from app.utils.auth import require_active_profile
@@ -36,6 +43,10 @@ async def dashboard(
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,
@@ -43,9 +54,52 @@ async def dashboard(
"volume_data_json": json.dumps(volume_data),
"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.get("/exercise/{exercise_id}", response_class=HTMLResponse)
async def exercise_progress(
exercise_id: int,

View File

@@ -0,0 +1,80 @@
"""Export service for generating CSV-ready workout history data.
Queries workout logs joined with sessions, days, and exercises
to produce flat rows suitable for CSV export.
"""
from datetime import date
import structlog
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
class ExportService:
"""Builds export-ready workout history rows.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def get_export_rows(
self, user_id: int, start_date: date, end_date: date,
) -> list[dict]:
"""Get flat workout log rows for CSV export.
Args:
user_id: The user whose data to export.
start_date: Inclusive start of date range.
end_date: Inclusive end of date range.
Returns:
List of dicts with keys: date, workout_type, exercise,
set_number, reps, weight, felt_easy.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(
WorkoutSession.user_id == user_id,
WorkoutSession.date >= start_date,
WorkoutSession.date <= end_date,
)
.order_by(WorkoutSession.date.asc())
).all()
if not sessions:
return []
# Pre-load exercises for name lookup
exercises = self._session.exec(select(Exercise)).all()
exercise_map = {e.id: e for e in exercises}
rows = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog)
.where(WorkoutLog.session_id == ws.id)
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
).all()
for log_entry in logs:
exercise = exercise_map.get(log_entry.exercise_id)
rows.append({
"date": ws.date.isoformat(),
"workout_type": exercise.workout_day if exercise else "Unknown",
"exercise": exercise.name if exercise else "Unknown",
"set_number": log_entry.set_number,
"reps": log_entry.reps_completed,
"weight": log_entry.weight_used,
"felt_easy": "Yes" if log_entry.felt_easy else "No",
})
return rows

View File

@@ -42,5 +42,8 @@
{% endfor %}
</ul>
</article>
<!-- Export -->
{% include "partials/export_form.html" %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
<article>
<header><h3>Export Workout History</h3></header>
<form method="get" action="/dashboard/export">
<div class="grid">
<label>
Start Date
<input type="date" name="start_date" value="{{ export_start_date }}">
</label>
<label>
End Date
<input type="date" name="end_date" value="{{ export_end_date }}">
</label>
<label>
&nbsp;
<button type="submit">Download CSV</button>
</label>
</div>
</form>
</article>