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. """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 json
import re
from datetime import date, timedelta
from typing import Optional
import structlog import structlog
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, StreamingResponse
from sqlmodel import Session from sqlmodel import Session
from app.database import get_db_session from app.database import get_db_session
from app.models.user import User from app.models.user import User
from app.services.analytics_service import AnalyticsService from app.services.analytics_service import AnalyticsService
from app.services.exercise_service import ExerciseService from app.services.exercise_service import ExerciseService
from app.services.export_service import ExportService
from app.services.progression_service import ProgressionService from app.services.progression_service import ProgressionService
from app.utils.auth import require_active_profile from app.utils.auth import require_active_profile
@@ -36,6 +43,10 @@ async def dashboard(
exercise_service = ExerciseService(session) exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises() exercises = exercise_service.list_exercises()
today = date.today()
export_start = (today - timedelta(days=30)).isoformat()
export_end = today.isoformat()
templates = request.app.state.templates templates = request.app.state.templates
return templates.TemplateResponse("pages/dashboard.html", { return templates.TemplateResponse("pages/dashboard.html", {
"request": request, "request": request,
@@ -43,9 +54,52 @@ async def dashboard(
"volume_data_json": json.dumps(volume_data), "volume_data_json": json.dumps(volume_data),
"exercises": exercises, "exercises": exercises,
"active_profile": profile, "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) @router.get("/exercise/{exercise_id}", response_class=HTMLResponse)
async def exercise_progress( async def exercise_progress(
exercise_id: int, 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 %} {% endfor %}
</ul> </ul>
</article> </article>
<!-- Export -->
{% include "partials/export_form.html" %}
{% endif %} {% endif %}
{% endblock %} {% 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>

View File

@@ -1,124 +1,9 @@
# SneakySwole Roadmap # SneakySwole Roadmap
## V2 Improvements ## V3 Improvements
Each improvement is implemented as a separate branch/PR. Implementation order matters — auth removal goes first since other features depend on the simplified profile flow. Each improvement is implemented as a separate branch/PR. Implementation order matters — auth removal goes first since other features depend on the simplified profile flow.
---
### ~~1. Easier Profile Selection~~ ✅ Complete
**Branch:** `feature/remove-auth` (merged in `758034b`)
Strip all authentication entirely — this is a homelab app, no need for login. When the app opens, the landing page (`/`) shows a profile picker with cards for each user. Selecting a profile sets an `active_profile_id` cookie that persists across sessions (per-device, until the user switches profiles or clears cookies).
**What gets removed:**
- Admin login flow (login page, bcrypt password verification, session tokens)
- `is_admin` and `password_hash` fields from the users table
- `auth_service.py`, `auth.py` routes, `login.html` template
- `bcrypt` and `itsdangerous` dependencies
- Auth dependency (`get_current_admin_user`) from all route handlers
**What gets added/changed:**
- `/` becomes the profile picker page (cards with name, height, weight, and "Select" button)
- `require_active_profile()` dependency replaces auth — just checks for the cookie
- `NoProfileSelectedError` handler redirects to `/` if no profile is set
- `NavContextMiddleware` simplified to only read the `active_profile_id` cookie
- Nav bar always shows profile switcher dropdown (no admin gating)
**Edge cases:**
- No profiles exist → show "Create Profile" link
- Cookie references a deleted profile → middleware returns None, redirect to picker
- Existing DB with admin row → migration deletes it
---
### ~~2. Schedule Adjustment — "Workout Now" Flow~~ ✅ Complete
**Branch:** `feature/workout-now` (committed in `4b117c6`) | **Depends on:** #1 merged
Replace the `/workouts` page (static list of all 4 days) with a smart "Workout Now" page that auto-recommends the next workout in the Push → Pull → Lower → Full Body cycle based on the user's last completed session.
**How it works:**
1. User selects profile → navigates to `/workouts`
2. App queries the most recent `workout_session` for that profile
3. Determines next day in cycle (e.g., last did Push → recommend Pull). No history defaults to Push.
4. Page shows all 4 workout types as cards, with the recommended one visually highlighted (`<mark>Recommended Next</mark>`, primary button vs outline for others)
5. User clicks "Start Workout" on any card → goes to that workout's exercise page
6. Once on a workout, a "Change Workout" button is visible to switch types (soft lock — not hard-locked, but the UI keeps you focused on the chosen workout)
**Card-based approach (no JS):** The recommendation is shown via highlighted cards rather than a dropdown/select. This follows the project's HTMX-first principle — no JavaScript needed.
**Display context:** Show "Last workout: Pull on Mar 10" message on the page so the user understands the recommendation.
**Edge cases:**
- Same-day multiple workout types → creates separate sessions (existing behavior, correct)
- No profile selected → show "pick a profile first" message with link to `/`
---
### ~~3. Automatic Scaled Workout Reps / Sets / Weights — 6→12 Rep Ladder~~ ✅ Complete
**Branch:** `feature/rep-ladder-progression` | **Depends on:** #1 merged
Replace the current wk1/wk4 target system with a fixed, universal rep ladder progression. Every exercise follows the same pattern:
**Progression model:**
- **Rep ladder:** 6 → 8 → 10 → 12 reps at current weight
- **Weight increase:** At 12 reps with all sets marked "felt easy" → +5 lbs, reset to 6 reps
- **Hold:** If not all sets felt easy at current reps → stay at current reps/weight
- **Climb:** If all sets felt easy but below 12 reps → move to next step in ladder
- **Deload:** After 4+ consecutive sessions struggling (none felt easy) → reduce weight by 20%, reset to 6 reps
- **Always 3 sets** per exercise
**Schema change:** Simplify `user_exercise_programs` table — keep only `starting_weight` per user/exercise (migrated from existing `wk1_weight` data). Drop `wk1_reps`, `wk4_reps`, `wk4_weight` columns. The existing 40 rows of programming data (20 exercises × 2 users) are preserved as starting weights.
**Starting weight behavior:**
- Exercise with no log history → suggest starting_weight at 6 reps, 3 sets
- Exercise with log history → progression engine picks up from last logged values
**UI change:** Replace the Week 1/Week 4 target display on exercise cards with a rep ladder progress bar showing the 4 steps (6, 8, 10, 12) with the current position filled in.
**Bodyweight exercises** (weight = "BW"): Weight increase is skipped. User climbs the rep ladder and holds at 12 reps.
**Data continuity:** The 55 existing workout log entries are unchanged and feed directly into the new progression engine. The progression_service.py gets a full rewrite of `get_suggestion()` with the new ladder logic.
---
### 4. Export Workout History as CSV
**Branch:** `feature/csv-export` | **Depends on:** #1 merged
Add a CSV export section to the dashboard page with a date range picker (default: last 30 days) and a "Download CSV" button.
**CSV format — one row per set:**
| Column | Description |
|--------|-------------|
| date | Workout session date (ISO format) |
| workout_type | Push / Pull / Lower / Full Body |
| exercise | Exercise name |
| set_number | Set number within that exercise |
| reps | Reps completed |
| weight | Weight used |
| felt_easy | Yes / No |
**Implementation:**
- `GET /dashboard/export?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD` returns a CSV file download
- Uses standard HTML form GET (not HTMX — HTMX can't trigger file downloads)
- `StreamingResponse` with `Content-Disposition: attachment` header
- Filename: `sneakyswole_{profile_name}_{start}_to_{end}.csv`
- Date range defaults: start = 30 days ago, end = today. Invalid dates fall back to defaults.
**Edge cases:**
- No data in range → CSV with headers only (valid empty CSV)
- No profile selected → redirect to dashboard
- Large date ranges are fine — even a year of heavy use is ~3,000 rows
---
## Implementation Order
```
1. Remove Auth ✅ ──┬──> 2. Workout Now ✅
├──> 3. Rep Ladder Progression ✅
└──> 4. CSV Export (can be parallel with 3)
```
--- ---