feat: enhance dashboard with PRs, adherence, activity, progression chart, and muscle heatmap
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13s

Add 3 new stat cards (Last Workout, Personal Records, Adherence Rate),
recent activity table, progression timeline chart, and muscle group
recency heatmap to the dashboard. Remove Total Volume card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:44:21 -05:00
parent c5a7728818
commit df8d5c65fb
8 changed files with 465 additions and 7 deletions

View File

@@ -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,

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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 %}

View 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>

View 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>

View 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>

View File

@@ -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>