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.
|
"""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,
|
||||||
|
|||||||
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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
{% include "partials/export_form.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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
|
# 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user