Compare commits
5 Commits
c5a7728818
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 059ba8f778 | |||
| 4e6930c207 | |||
| cea1b4e80e | |||
| bae0bc9dee | |||
| df8d5c65fb |
@@ -7,12 +7,14 @@ and CSV export of workout history.
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ 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.export_service import ExportService
|
||||||
|
from app.services.import_service import ImportService
|
||||||
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
|
||||||
|
|
||||||
@@ -39,6 +42,11 @@ async def dashboard(
|
|||||||
analytics = AnalyticsService(session)
|
analytics = AnalyticsService(session)
|
||||||
stats = analytics.get_user_stats(profile.id)
|
stats = analytics.get_user_stats(profile.id)
|
||||||
volume_data = analytics.get_volume_by_day(profile.id)
|
volume_data = analytics.get_volume_by_day(profile.id)
|
||||||
|
personal_records = analytics.get_personal_records(profile.id)
|
||||||
|
adherence = analytics.get_adherence_rate(profile.id)
|
||||||
|
progression_timeline = analytics.get_progression_timeline(profile.id)
|
||||||
|
muscle_recency = analytics.get_muscle_group_recency(profile.id)
|
||||||
|
recent_activity = analytics.get_recent_activity(profile.id)
|
||||||
|
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
exercises = exercise_service.list_exercises()
|
exercises = exercise_service.list_exercises()
|
||||||
@@ -52,6 +60,11 @@ async def dashboard(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"volume_data_json": json.dumps(volume_data),
|
"volume_data_json": json.dumps(volume_data),
|
||||||
|
"personal_records": personal_records,
|
||||||
|
"adherence": adherence,
|
||||||
|
"progression_timeline_json": json.dumps(progression_timeline),
|
||||||
|
"muscle_recency": muscle_recency,
|
||||||
|
"recent_activity": recent_activity,
|
||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
"active_profile": profile,
|
"active_profile": profile,
|
||||||
"export_start_date": export_start,
|
"export_start_date": export_start,
|
||||||
@@ -100,6 +113,36 @@ async def export_csv(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_class=HTMLResponse)
|
||||||
|
async def import_db(
|
||||||
|
request: Request,
|
||||||
|
db_file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
profile: User = Depends(require_active_profile),
|
||||||
|
):
|
||||||
|
"""Import workout history from an old SneakySwole database file."""
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db")
|
||||||
|
try:
|
||||||
|
with os.fdopen(tmp_fd, "wb") as tmp:
|
||||||
|
content = await db_file.read()
|
||||||
|
logger.info("import_upload_received", filename=db_file.filename, size=len(content))
|
||||||
|
tmp.write(content)
|
||||||
|
|
||||||
|
import_service = ImportService(session)
|
||||||
|
result = import_service.import_from_db(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("import_failed")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/import_results.html", {
|
||||||
|
"request": request,
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from datetime import date, timedelta
|
|||||||
import structlog
|
import structlog
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.progress_log import ProgressLog
|
||||||
from app.models.workout_day import WorkoutDay
|
from app.models.workout_day import WorkoutDay
|
||||||
from app.models.workout_log import WorkoutLog
|
from app.models.workout_log import WorkoutLog
|
||||||
from app.models.workout_session import WorkoutSession
|
from app.models.workout_session import WorkoutSession
|
||||||
@@ -179,3 +181,197 @@ class AnalyticsService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return volume_by_day
|
return volume_by_day
|
||||||
|
|
||||||
|
def get_personal_records(self, user_id: int) -> list[dict]:
|
||||||
|
"""Get per-exercise max weight records for a user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with exercise_name, weight, weight_display, date.
|
||||||
|
Sorted by weight descending. BW-only exercises excluded.
|
||||||
|
"""
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Map exercise_id -> {max_weight, weight_str, date, exercise_name}
|
||||||
|
records: dict[int, dict] = {}
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
for log_entry in logs:
|
||||||
|
weight = _weight_to_float(log_entry.weight_used)
|
||||||
|
if weight == 0.0:
|
||||||
|
continue
|
||||||
|
existing = records.get(log_entry.exercise_id)
|
||||||
|
if existing is None or weight > existing["weight"]:
|
||||||
|
records[log_entry.exercise_id] = {
|
||||||
|
"exercise_id": log_entry.exercise_id,
|
||||||
|
"weight": weight,
|
||||||
|
"weight_display": log_entry.weight_used,
|
||||||
|
"date": ws.date,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve exercise names
|
||||||
|
result = []
|
||||||
|
for exercise_id, rec in records.items():
|
||||||
|
exercise = self._session.get(Exercise, exercise_id)
|
||||||
|
if exercise:
|
||||||
|
rec["exercise_name"] = exercise.name
|
||||||
|
result.append(rec)
|
||||||
|
|
||||||
|
result.sort(key=lambda r: r["weight"], reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_adherence_rate(self, user_id: int, weeks: int = 8) -> dict:
|
||||||
|
"""Calculate workout adherence rate over the past N weeks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with rate (0-100), completed, expected, weeks.
|
||||||
|
"""
|
||||||
|
cutoff = date.today() - timedelta(weeks=weeks)
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(
|
||||||
|
WorkoutSession.user_id == user_id,
|
||||||
|
WorkoutSession.date >= cutoff,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Only count sessions with logs
|
||||||
|
completed = 0
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
if logs:
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
expected = weeks * 4
|
||||||
|
rate = round((completed / expected) * 100) if expected > 0 else 0
|
||||||
|
return {
|
||||||
|
"rate": min(rate, 100),
|
||||||
|
"completed": completed,
|
||||||
|
"expected": expected,
|
||||||
|
"weeks": weeks,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_muscle_group_recency(self, user_id: int) -> list[dict]:
|
||||||
|
"""Get the most recent workout date for each muscle group.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with muscle_group, last_worked, days_ago.
|
||||||
|
Sorted by days_ago descending (most stale first).
|
||||||
|
"""
|
||||||
|
exercises = self._session.exec(select(Exercise)).all()
|
||||||
|
muscle_groups = {e.muscle_group for e in exercises if e.muscle_group}
|
||||||
|
|
||||||
|
# Map exercise_id -> muscle_group for fast lookup
|
||||||
|
ex_muscle = {e.id: e.muscle_group for e in exercises}
|
||||||
|
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
recency: dict[str, date] = {}
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
for log_entry in logs:
|
||||||
|
mg = ex_muscle.get(log_entry.exercise_id, "")
|
||||||
|
if mg and (mg not in recency or ws.date > recency[mg]):
|
||||||
|
recency[mg] = ws.date
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
result = []
|
||||||
|
for mg in sorted(muscle_groups):
|
||||||
|
last_worked = recency.get(mg)
|
||||||
|
days_ago = (today - last_worked).days if last_worked else None
|
||||||
|
result.append({
|
||||||
|
"muscle_group": mg,
|
||||||
|
"last_worked": last_worked,
|
||||||
|
"days_ago": days_ago,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort: never-worked first, then most stale
|
||||||
|
result.sort(
|
||||||
|
key=lambda r: (r["days_ago"] is None, -(r["days_ago"] or 0)),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_recent_activity(self, user_id: int, limit: int = 5) -> list[dict]:
|
||||||
|
"""Get the last N workout sessions with summary data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with date, workout_day_name, total_volume, total_sets.
|
||||||
|
"""
|
||||||
|
days = self._session.exec(select(WorkoutDay)).all()
|
||||||
|
day_map = {d.id: d.name for d in days}
|
||||||
|
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
if not logs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_volume = sum(
|
||||||
|
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
|
||||||
|
for log_entry in logs
|
||||||
|
)
|
||||||
|
result.append({
|
||||||
|
"date": ws.date,
|
||||||
|
"workout_day_name": day_map.get(ws.workout_day_id, "Unknown"),
|
||||||
|
"total_volume": round(total_volume),
|
||||||
|
"total_sets": len(logs),
|
||||||
|
})
|
||||||
|
if len(result) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_progression_timeline(self, user_id: int) -> dict:
|
||||||
|
"""Get progression history for Chart.js multi-line chart.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'exercises' key mapping exercise names to
|
||||||
|
{dates, weights, events} lists.
|
||||||
|
"""
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(ProgressLog)
|
||||||
|
.where(ProgressLog.user_id == user_id)
|
||||||
|
.order_by(ProgressLog.date.asc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
exercises: dict[int, list] = {}
|
||||||
|
for pl in logs:
|
||||||
|
exercises.setdefault(pl.exercise_id, []).append(pl)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for exercise_id, entries in exercises.items():
|
||||||
|
exercise = self._session.get(Exercise, exercise_id)
|
||||||
|
if not exercise:
|
||||||
|
continue
|
||||||
|
name = exercise.name
|
||||||
|
result[name] = {
|
||||||
|
"dates": [e.date.isoformat() for e in entries],
|
||||||
|
"weights": [
|
||||||
|
_weight_to_float(e.actual_weight or e.suggested_weight or "0")
|
||||||
|
for e in entries
|
||||||
|
],
|
||||||
|
"events": [e.progression_applied or "" for e in entries],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"exercises": result}
|
||||||
|
|||||||
281
app/services/import_service.py
Normal file
281
app/services/import_service.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Import service for loading workout history from an old database.
|
||||||
|
|
||||||
|
Opens the old SQLite DB read-only, maps IDs by name, and imports
|
||||||
|
workout_sessions, workout_logs, and progress_log into the current DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.progress_log import ProgressLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportResult:
|
||||||
|
"""Tracks counts and warnings from an import operation."""
|
||||||
|
|
||||||
|
sessions_imported: int = 0
|
||||||
|
sessions_skipped: int = 0
|
||||||
|
logs_imported: int = 0
|
||||||
|
logs_skipped: int = 0
|
||||||
|
progress_imported: int = 0
|
||||||
|
progress_skipped: int = 0
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportService:
|
||||||
|
"""Imports workout history from an old SneakySwole database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session for the current DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def import_from_db(self, db_path: str) -> ImportResult:
|
||||||
|
"""Import workout data from an old database file.
|
||||||
|
|
||||||
|
Matches users, exercises, and workout days by name between
|
||||||
|
old and new databases. Skips duplicates for idempotency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the old SQLite database file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ImportResult with counts and any warnings.
|
||||||
|
"""
|
||||||
|
result = ImportResult()
|
||||||
|
|
||||||
|
old_db = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||||
|
old_db.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
user_map = self._build_user_map(old_db, result)
|
||||||
|
exercise_map = self._build_exercise_map(old_db, result)
|
||||||
|
workout_day_map = self._build_workout_day_map(old_db, result)
|
||||||
|
|
||||||
|
session_map = self._import_sessions(
|
||||||
|
old_db, user_map, workout_day_map, result,
|
||||||
|
)
|
||||||
|
self._import_logs(old_db, session_map, exercise_map, result)
|
||||||
|
self._import_progress(old_db, user_map, exercise_map, result)
|
||||||
|
|
||||||
|
self._session.commit()
|
||||||
|
finally:
|
||||||
|
old_db.close()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"import_complete",
|
||||||
|
sessions=result.sessions_imported,
|
||||||
|
logs=result.logs_imported,
|
||||||
|
progress=result.progress_imported,
|
||||||
|
warnings=len(result.warnings),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _build_user_map(
|
||||||
|
self, old_db: sqlite3.Connection, result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Map old user IDs to new user IDs by matching on username."""
|
||||||
|
new_users = self._session.exec(select(User)).all()
|
||||||
|
new_user_by_name = {u.username: u.id for u in new_users}
|
||||||
|
|
||||||
|
user_map: dict[int, int] = {}
|
||||||
|
for row in old_db.execute("SELECT id, username FROM users"):
|
||||||
|
new_id = new_user_by_name.get(row["username"])
|
||||||
|
if new_id is not None:
|
||||||
|
user_map[row["id"]] = new_id
|
||||||
|
else:
|
||||||
|
result.warnings.append(
|
||||||
|
f"User '{row['username']}' (old id={row['id']}) not found in new DB — skipping their data",
|
||||||
|
)
|
||||||
|
return user_map
|
||||||
|
|
||||||
|
def _build_exercise_map(
|
||||||
|
self, old_db: sqlite3.Connection, result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Map old exercise IDs to new exercise IDs by matching on name."""
|
||||||
|
new_exercises = self._session.exec(select(Exercise)).all()
|
||||||
|
new_ex_by_name = {e.name: e.id for e in new_exercises}
|
||||||
|
|
||||||
|
exercise_map: dict[int, int] = {}
|
||||||
|
for row in old_db.execute("SELECT id, name FROM exercises"):
|
||||||
|
new_id = new_ex_by_name.get(row["name"])
|
||||||
|
if new_id is not None:
|
||||||
|
exercise_map[row["id"]] = new_id
|
||||||
|
else:
|
||||||
|
result.warnings.append(
|
||||||
|
f"Exercise '{row['name']}' (old id={row['id']}) not found in new DB — skipping its logs",
|
||||||
|
)
|
||||||
|
return exercise_map
|
||||||
|
|
||||||
|
def _build_workout_day_map(
|
||||||
|
self, old_db: sqlite3.Connection, result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Map old workout_day IDs to new ones by matching on name."""
|
||||||
|
new_days = self._session.exec(select(WorkoutDay)).all()
|
||||||
|
new_day_by_name = {d.name: d.id for d in new_days}
|
||||||
|
|
||||||
|
day_map: dict[int, int] = {}
|
||||||
|
for row in old_db.execute("SELECT id, name FROM workout_days"):
|
||||||
|
new_id = new_day_by_name.get(row["name"])
|
||||||
|
if new_id is not None:
|
||||||
|
day_map[row["id"]] = new_id
|
||||||
|
else:
|
||||||
|
result.warnings.append(
|
||||||
|
f"Workout day '{row['name']}' (old id={row['id']}) not found in new DB",
|
||||||
|
)
|
||||||
|
return day_map
|
||||||
|
|
||||||
|
def _import_sessions(
|
||||||
|
self,
|
||||||
|
old_db: sqlite3.Connection,
|
||||||
|
user_map: dict[int, int],
|
||||||
|
workout_day_map: dict[int, int],
|
||||||
|
result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Import workout_sessions, returning old_id -> new_id map."""
|
||||||
|
session_map: dict[int, int] = {}
|
||||||
|
|
||||||
|
rows = old_db.execute(
|
||||||
|
"SELECT id, user_id, workout_day_id, date, notes, created_at "
|
||||||
|
"FROM workout_sessions ORDER BY id",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
new_user_id = user_map.get(row["user_id"])
|
||||||
|
new_day_id = workout_day_map.get(row["workout_day_id"])
|
||||||
|
if new_user_id is None or new_day_id is None:
|
||||||
|
result.sessions_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for duplicate by (user_id, workout_day_id, date)
|
||||||
|
existing = self._session.exec(
|
||||||
|
select(WorkoutSession).where(
|
||||||
|
WorkoutSession.user_id == new_user_id,
|
||||||
|
WorkoutSession.workout_day_id == new_day_id,
|
||||||
|
WorkoutSession.date == row["date"],
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
session_map[row["id"]] = existing.id
|
||||||
|
result.sessions_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_session = WorkoutSession(
|
||||||
|
user_id=new_user_id,
|
||||||
|
workout_day_id=new_day_id,
|
||||||
|
date=date.fromisoformat(row["date"]),
|
||||||
|
notes=row["notes"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
)
|
||||||
|
self._session.add(new_session)
|
||||||
|
self._session.flush()
|
||||||
|
session_map[row["id"]] = new_session.id
|
||||||
|
result.sessions_imported += 1
|
||||||
|
|
||||||
|
return session_map
|
||||||
|
|
||||||
|
def _import_logs(
|
||||||
|
self,
|
||||||
|
old_db: sqlite3.Connection,
|
||||||
|
session_map: dict[int, int],
|
||||||
|
exercise_map: dict[int, int],
|
||||||
|
result: ImportResult,
|
||||||
|
) -> None:
|
||||||
|
"""Import workout_logs using mapped session and exercise IDs."""
|
||||||
|
rows = old_db.execute(
|
||||||
|
"SELECT session_id, exercise_id, set_number, reps_completed, "
|
||||||
|
"weight_used, felt_easy, notes, created_at "
|
||||||
|
"FROM workout_logs ORDER BY id",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
new_session_id = session_map.get(row["session_id"])
|
||||||
|
new_exercise_id = exercise_map.get(row["exercise_id"])
|
||||||
|
if new_session_id is None or new_exercise_id is None:
|
||||||
|
result.logs_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = self._session.exec(
|
||||||
|
select(WorkoutLog).where(
|
||||||
|
WorkoutLog.session_id == new_session_id,
|
||||||
|
WorkoutLog.exercise_id == new_exercise_id,
|
||||||
|
WorkoutLog.set_number == row["set_number"],
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
result.logs_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._session.add(WorkoutLog(
|
||||||
|
session_id=new_session_id,
|
||||||
|
exercise_id=new_exercise_id,
|
||||||
|
set_number=row["set_number"],
|
||||||
|
reps_completed=row["reps_completed"],
|
||||||
|
weight_used=row["weight_used"],
|
||||||
|
felt_easy=bool(row["felt_easy"]),
|
||||||
|
notes=row["notes"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
))
|
||||||
|
result.logs_imported += 1
|
||||||
|
|
||||||
|
def _import_progress(
|
||||||
|
self,
|
||||||
|
old_db: sqlite3.Connection,
|
||||||
|
user_map: dict[int, int],
|
||||||
|
exercise_map: dict[int, int],
|
||||||
|
result: ImportResult,
|
||||||
|
) -> None:
|
||||||
|
"""Import progress_log using mapped user and exercise IDs."""
|
||||||
|
rows = old_db.execute(
|
||||||
|
"SELECT user_id, exercise_id, date, suggested_reps, "
|
||||||
|
"suggested_weight, actual_reps, actual_weight, "
|
||||||
|
"progression_applied, created_at "
|
||||||
|
"FROM progress_log ORDER BY id",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
new_user_id = user_map.get(row["user_id"])
|
||||||
|
new_exercise_id = exercise_map.get(row["exercise_id"])
|
||||||
|
if new_user_id is None or new_exercise_id is None:
|
||||||
|
result.progress_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = self._session.exec(
|
||||||
|
select(ProgressLog).where(
|
||||||
|
ProgressLog.user_id == new_user_id,
|
||||||
|
ProgressLog.exercise_id == new_exercise_id,
|
||||||
|
ProgressLog.date == row["date"],
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
result.progress_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._session.add(ProgressLog(
|
||||||
|
user_id=new_user_id,
|
||||||
|
exercise_id=new_exercise_id,
|
||||||
|
date=date.fromisoformat(row["date"]),
|
||||||
|
suggested_reps=row["suggested_reps"],
|
||||||
|
suggested_weight=row["suggested_weight"],
|
||||||
|
actual_reps=row["actual_reps"],
|
||||||
|
actual_weight=row["actual_weight"],
|
||||||
|
progression_applied=row["progression_applied"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
))
|
||||||
|
result.progress_imported += 1
|
||||||
@@ -34,3 +34,36 @@ main.container {
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Muscle group heatmap */
|
||||||
|
.muscle-heatmap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muscle-heatmap-cell {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-fresh {
|
||||||
|
background: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-ok {
|
||||||
|
background: rgba(234, 179, 8, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-stale {
|
||||||
|
background: rgba(249, 115, 22, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-overdue {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-never {
|
||||||
|
background: rgba(107, 114, 128, 0.3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -28,6 +29,15 @@
|
|||||||
{% include "partials/volume_chart.html" %}
|
{% include "partials/volume_chart.html" %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
{% include "partials/recent_activity.html" %}
|
||||||
|
|
||||||
|
<!-- Progression Timeline -->
|
||||||
|
{% include "partials/progression_chart.html" %}
|
||||||
|
|
||||||
|
<!-- Muscle Group Heatmap -->
|
||||||
|
{% include "partials/muscle_heatmap.html" %}
|
||||||
|
|
||||||
<!-- Exercise Progress Links -->
|
<!-- Exercise Progress Links -->
|
||||||
<article>
|
<article>
|
||||||
<header><h3>Per-Exercise Progress</h3></header>
|
<header><h3>Per-Exercise Progress</h3></header>
|
||||||
@@ -45,5 +55,9 @@
|
|||||||
|
|
||||||
<!-- Export -->
|
<!-- Export -->
|
||||||
{% include "partials/export_form.html" %}
|
{% include "partials/export_form.html" %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Import -->
|
||||||
|
{% include "partials/import_form.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
17
app/templates/partials/import_form.html
Normal file
17
app/templates/partials/import_form.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Import Old Database</h3></header>
|
||||||
|
<form hx-post="/dashboard/import" hx-encoding="multipart/form-data"
|
||||||
|
hx-target="#import-results" hx-swap="innerHTML">
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
Old Database File (.db)
|
||||||
|
<input type="file" name="db_file" accept=".db" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
|
||||||
|
<button type="submit">Import</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="import-results"></div>
|
||||||
|
</article>
|
||||||
37
app/templates/partials/import_results.html
Normal file
37
app/templates/partials/import_results.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Imported</th>
|
||||||
|
<th>Skipped</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Workout Sessions</td>
|
||||||
|
<td>{{ result.sessions_imported }}</td>
|
||||||
|
<td>{{ result.sessions_skipped }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Workout Logs</td>
|
||||||
|
<td>{{ result.logs_imported }}</td>
|
||||||
|
<td>{{ result.logs_skipped }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Progress Log</td>
|
||||||
|
<td>{{ result.progress_imported }}</td>
|
||||||
|
<td>{{ result.progress_skipped }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if result.warnings %}
|
||||||
|
<details>
|
||||||
|
<summary>Warnings ({{ result.warnings|length }})</summary>
|
||||||
|
<ul>
|
||||||
|
{% for warning in result.warnings %}
|
||||||
|
<li><small>{{ warning }}</small></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
24
app/templates/partials/muscle_heatmap.html
Normal file
24
app/templates/partials/muscle_heatmap.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Muscle Group Heatmap</h3></header>
|
||||||
|
{% if muscle_recency %}
|
||||||
|
<div class="muscle-heatmap">
|
||||||
|
{% for mg in muscle_recency %}
|
||||||
|
<div class="muscle-heatmap-cell {% if mg.days_ago is none %}recency-never{% elif mg.days_ago <= 3 %}recency-fresh{% elif mg.days_ago <= 7 %}recency-ok{% elif mg.days_ago <= 14 %}recency-stale{% else %}recency-overdue{% endif %}">
|
||||||
|
<strong>{{ mg.muscle_group }}</strong>
|
||||||
|
<br>
|
||||||
|
{% if mg.days_ago is none %}
|
||||||
|
<small>Never worked</small>
|
||||||
|
{% elif mg.days_ago == 0 %}
|
||||||
|
<small>Today</small>
|
||||||
|
{% elif mg.days_ago == 1 %}
|
||||||
|
<small>1 day ago</small>
|
||||||
|
{% else %}
|
||||||
|
<small>{{ mg.days_ago }} days ago</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No exercises found in the library.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="/">Home</a></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li><a href="/workouts">Workouts</a></li>
|
|
||||||
<li><a href="/dashboard">Dashboard</a></li>
|
<li><a href="/dashboard">Dashboard</a></li>
|
||||||
|
<li><a href="/workouts">Workouts</a></li>
|
||||||
<li><a href="/history">History</a></li>
|
<li><a href="/history">History</a></li>
|
||||||
<li><a href="/exercises">Exercises</a></li>
|
<li><a href="/exercises">Exercises</a></li>
|
||||||
<li><a href="/profiles">Profiles</a></li>
|
<li><a href="/profiles">Profiles</a></li>
|
||||||
|
|||||||
90
app/templates/partials/progression_chart.html
Normal file
90
app/templates/partials/progression_chart.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Progression Timeline</h3></header>
|
||||||
|
<div id="progression-container">
|
||||||
|
<canvas id="progression-chart" style="max-height:350px;"></canvas>
|
||||||
|
<div style="margin-top:0.5rem;">
|
||||||
|
<label for="progression-filter" style="display:inline; margin-right:0.5rem;">Exercise:</label>
|
||||||
|
<select id="progression-filter" style="display:inline-block; width:auto;">
|
||||||
|
<option value="all">All Exercises</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="progression-empty" style="display:none;">No progression data yet.</p>
|
||||||
|
</article>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var timeline = {{ progression_timeline_json|safe }};
|
||||||
|
var exerciseData = timeline.exercises || {};
|
||||||
|
var names = Object.keys(exerciseData);
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
document.getElementById('progression-container').style.display = 'none';
|
||||||
|
document.getElementById('progression-empty').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colors = [
|
||||||
|
'rgba(99, 102, 241, 1)',
|
||||||
|
'rgba(34, 197, 94, 1)',
|
||||||
|
'rgba(234, 179, 8, 1)',
|
||||||
|
'rgba(249, 115, 22, 1)',
|
||||||
|
'rgba(239, 68, 68, 1)',
|
||||||
|
'rgba(168, 85, 247, 1)',
|
||||||
|
'rgba(20, 184, 166, 1)',
|
||||||
|
'rgba(236, 72, 153, 1)',
|
||||||
|
];
|
||||||
|
|
||||||
|
var datasets = [];
|
||||||
|
var filterSelect = document.getElementById('progression-filter');
|
||||||
|
|
||||||
|
names.forEach(function(name, i) {
|
||||||
|
var d = exerciseData[name];
|
||||||
|
datasets.push({
|
||||||
|
label: name,
|
||||||
|
data: d.dates.map(function(dt, j) {
|
||||||
|
return {x: dt, y: d.weights[j]};
|
||||||
|
}),
|
||||||
|
borderColor: colors[i % colors.length],
|
||||||
|
backgroundColor: colors[i % colors.length].replace('1)', '0.2)'),
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = i;
|
||||||
|
opt.textContent = name;
|
||||||
|
filterSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
var chart = new Chart(document.getElementById('progression-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {datasets: datasets},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {labels: {color: '#ccc'}},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {unit: 'week'},
|
||||||
|
ticks: {color: '#ccc'},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
title: {display: true, text: 'Weight (lbs)', color: '#ccc'},
|
||||||
|
ticks: {color: '#ccc'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
filterSelect.addEventListener('change', function() {
|
||||||
|
var val = this.value;
|
||||||
|
chart.data.datasets.forEach(function(ds, i) {
|
||||||
|
ds.hidden = (val !== 'all' && i !== parseInt(val));
|
||||||
|
});
|
||||||
|
chart.update();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
29
app/templates/partials/recent_activity.html
Normal file
29
app/templates/partials/recent_activity.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Recent Activity</h3></header>
|
||||||
|
{% if recent_activity %}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Workout</th>
|
||||||
|
<th>Volume (lbs)</th>
|
||||||
|
<th>Sets</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for session in recent_activity %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ session.date.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td>{{ session.workout_day_name }}</td>
|
||||||
|
<td>{{ "{:,}".format(session.total_volume) }}</td>
|
||||||
|
<td>{{ session.total_sets }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No workout sessions logged yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
@@ -4,12 +4,6 @@
|
|||||||
{{ stats.total_sessions }}
|
{{ stats.total_sessions }}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
|
||||||
<header><h4>Total Volume</h4></header>
|
|
||||||
<p style="font-size:2rem; font-weight:700;">
|
|
||||||
{{ "{:,}".format(stats.total_volume) }} lbs
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
<article>
|
||||||
<header><h4>Total Sets</h4></header>
|
<header><h4>Total Sets</h4></header>
|
||||||
<p style="font-size:2rem; font-weight:700;">
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
@@ -22,3 +16,38 @@
|
|||||||
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
|
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article>
|
||||||
|
<header><h4>Last Workout</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{% if stats.last_workout_date %}
|
||||||
|
{{ stats.last_workout_date.strftime('%b %d') }}
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><h4>Personal Records</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ personal_records|length }} PR{{ "s" if personal_records|length != 1 }}
|
||||||
|
</p>
|
||||||
|
{% if personal_records %}
|
||||||
|
<details>
|
||||||
|
<summary>View records</summary>
|
||||||
|
<ul style="font-size:0.85rem; margin-top:0.5rem;">
|
||||||
|
{% for pr in personal_records[:10] %}
|
||||||
|
<li>{{ pr.exercise_name }}: {{ pr.weight_display }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><h4>Adherence</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ adherence.rate }}%
|
||||||
|
</p>
|
||||||
|
<small>{{ adherence.completed }}/{{ adherence.expected }} sessions ({{ adherence.weeks }}wk)</small>
|
||||||
|
</article>
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
services:
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: sneakyswole-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
networks:
|
||||||
|
- sneakyswole
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
|
image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
|
||||||
container_name: sneakyswole
|
container_name: sneakyswole
|
||||||
ports:
|
expose:
|
||||||
- "${APP_PORT:-8000}:8000"
|
- "8000"
|
||||||
volumes:
|
volumes:
|
||||||
- sneakyswole-data:/app/data
|
- sneakyswole-data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
networks:
|
||||||
|
- sneakyswole
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
@@ -16,6 +32,12 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sneakyswole:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sneakyswole-data:
|
sneakyswole-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
caddy-data:
|
||||||
|
driver: local
|
||||||
|
|||||||
Reference in New Issue
Block a user