feat: add Phase 5 Progression & Analytics — smart suggestions, dashboard, schedule

Add auto-progression engine (ProgressionService) with rep increase, weight
increase, deload, and felt-easy acceleration rules. Add AnalyticsService for
user stats, exercise progress charts, and volume-by-day data. New dashboard
and schedule routes with Chart.js visualizations. Progression badges shown
inline on workout day view. Navigation updated with Dashboard and Schedule links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:26:23 -06:00
parent e35b78ae87
commit 134542b66f
19 changed files with 1209 additions and 0 deletions

View File

@@ -19,6 +19,11 @@
</div>
{% endif %}
{% if suggestions and suggestions[exercise.id] %}
{% set suggestion = suggestions[exercise.id] %}
{% include "partials/progression_badge.html" %}
{% endif %}
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>

View File

@@ -27,6 +27,8 @@
</details>
</li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/schedule">Schedule</a></li>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/history">History</a></li>
<li><a href="/exercises">Exercises</a></li>
<li><a href="/profiles">Profiles</a></li>

View File

@@ -0,0 +1,56 @@
<canvas id="progress-chart" style="max-height:300px;"></canvas>
<p id="progress-chart-empty" style="display:none;">No data yet.</p>
<script>
(function() {
var data = {{ progress_data_json|safe }};
if (!data.dates || data.dates.length === 0) {
document.getElementById('progress-chart').style.display = 'none';
document.getElementById('progress-chart-empty').style.display = 'block';
return;
}
new Chart(document.getElementById('progress-chart'), {
type: 'line',
data: {
labels: data.dates,
datasets: [
{
label: 'Avg Reps',
data: data.reps,
borderColor: 'rgba(99, 102, 241, 1)',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
yAxisID: 'y-reps',
tension: 0.3
},
{
label: 'Weight (lbs)',
data: data.weights,
borderColor: 'rgba(244, 63, 94, 1)',
backgroundColor: 'rgba(244, 63, 94, 0.2)',
yAxisID: 'y-weight',
tension: 0.3
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
'y-reps': {
type: 'linear', display: true, position: 'left',
title: { display: true, text: 'Reps', color: '#ccc' },
ticks: { color: '#ccc' }
},
'y-weight': {
type: 'linear', display: true, position: 'right',
title: { display: true, text: 'Weight (lbs)', color: '#ccc' },
ticks: { color: '#ccc' },
grid: { drawOnChartArea: false }
},
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>

View File

@@ -0,0 +1,11 @@
{% if suggestion and suggestion.progression_type != "no_program" %}
<div style="background: rgba(99, 102, 241, 0.1);
border-left: 3px solid var(--pico-primary);
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0 0.25rem 0.25rem 0;">
<small>
<strong>Suggestion:</strong> {{ suggestion.message }}
</small>
</div>
{% endif %}

View File

@@ -0,0 +1,24 @@
<article>
<header><h4>Sessions</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sessions }}
</p>
</article>
<article>
<header><h4>Total Volume</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ "{:,}".format(stats.total_volume) }} lbs
</p>
</article>
<article>
<header><h4>Total Sets</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sets }}
</p>
</article>
<article>
<header><h4>Streak</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
</p>
</article>

View File

@@ -0,0 +1,30 @@
<canvas id="volume-chart" style="max-height:300px;"></canvas>
<script>
(function() {
var data = {{ volume_data_json|safe }};
var labels = Object.keys(data);
var values = Object.values(data);
new Chart(document.getElementById('volume-chart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Total Volume (lbs)',
data: values,
backgroundColor: 'rgba(99, 102, 241, 0.7)',
borderColor: 'rgba(99, 102, 241, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { color: '#ccc' } },
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>