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:
@@ -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,
|
||||
|
||||
80
app/services/export_service.py
Normal file
80
app/services/export_service.py
Normal 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
|
||||
@@ -42,5 +42,8 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<!-- Export -->
|
||||
{% include "partials/export_form.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
19
app/templates/partials/export_form.html
Normal file
19
app/templates/partials/export_form.html
Normal 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>
|
||||
|
||||
<button type="submit">Download CSV</button>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
Reference in New Issue
Block a user