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>
|
||||
117
docs/roadmap.md
117
docs/roadmap.md
@@ -1,124 +1,9 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
### ~~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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user