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,