Simplify forecast panel and extend horizon

This commit is contained in:
Peter Stockings
2026-09-06 14:50:51 +10:00
parent d26da9a066
commit 0dea94fec0
5 changed files with 25 additions and 31 deletions
+1 -1
View File
@@ -243,7 +243,7 @@ def get_exercise_progress_for_user(person_id, exercise_id):
degree = request.args.get('degree', type=int, default=1)
forecast_enabled = request.args.get('forecast', '').lower() in ('1', 'true', 'yes', 'on')
forecast_weeks = request.args.get('forecast_weeks', type=int, default=8)
forecast_weeks = max(4, min(forecast_weeks, 26))
forecast_weeks = max(4, min(forecast_weeks, 52))
forecast_target_reps = request.args.get('forecast_reps', type=int)
if forecast_target_reps is not None:
forecast_target_reps = max(1, min(forecast_target_reps, 20))
+1 -1
View File
@@ -303,7 +303,7 @@ def get_exercise_history(person_id, exercise_id):
source_topset_id = request.args.get('source_topset_id', type=int)
forecast_enabled = request.args.get('forecast', '').lower() in ('1', 'true', 'yes', 'on')
forecast_weeks = request.args.get('forecast_weeks', type=int, default=8)
forecast_weeks = max(4, min(forecast_weeks, 26))
forecast_weeks = max(4, min(forecast_weeks, 52))
forecast_target_reps = request.args.get('forecast_reps', type=int)
if forecast_target_reps is not None:
forecast_target_reps = max(1, min(forecast_target_reps, 20))
+1 -1
View File
@@ -23,7 +23,7 @@
</button>
</div>
</div>
{% if best_fit_formula %}
{% if best_fit_formula and not forecast_enabled %}
<h2 class="text-xs font-semibold text-blue-200 mb-4 text-center">
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
</h2>
+16 -25
View File
@@ -34,6 +34,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
{% macro forecast_circles(data_points, details, color) %}
{% for value, position in data_points %}
{% if loop.last %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1.6" fill="{{ color }}" stroke="{{ color }}"
@@ -43,6 +44,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
data-e1rm="{{ details[loop.index0].e1rm }}"
data-topset="{{ details[loop.index0].top_set }}"
data-range="{{ details[loop.index0].range }}"></circle>
{% endif %}
{% endfor %}
{% endmacro %}
@@ -102,7 +104,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
</div>
</div>
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
{% if best_fit_formula %}
{% if best_fit_formula and not forecast_enabled %}
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
{% endif %}
</h2>
@@ -127,43 +129,32 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
{% endfor %}
</div>
{% if forecast_enabled %}
<div class="mx-2 mb-3 rounded-xl border {% if forecast.available %}border-emerald-100 bg-emerald-50/70{% else %}border-amber-100 bg-amber-50/70{% endif %} p-3 text-left">
<div class="mx-2 mb-3 rounded-xl border {% if forecast.available %}border-emerald-100 bg-emerald-50/70{% else %}border-amber-100 bg-amber-50/70{% endif %} p-2.5 text-left">
{% if forecast.available %}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1.5">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800">E1RM projection</div>
<div class="text-sm text-gray-700 mt-0.5">{{ forecast.last_value }}kg now <span class="text-gray-400">→</span> <span class="font-bold text-emerald-800">{{ forecast.projected_value }}kg</span> in {{ forecast.horizon_weeks }} weeks</div>
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800">Forecast · {{ forecast.horizon_weeks }} weeks</div>
<div class="text-lg font-bold text-gray-800 mt-0.5">{{ forecast.projected_value }}kg <span class="text-xs font-normal text-gray-500">projected E1RM</span></div>
</div>
<div class="text-left sm:text-right">
<div class="text-sm font-semibold {% if forecast.weekly_change >= 0 %}text-emerald-700{% else %}text-rose-700{% endif %}">{% if forecast.weekly_change >= 0 %}+{% endif %}{{ forecast.weekly_change }}kg/week</div>
<div class="text-sm font-semibold {% if forecast.projected_change >= 0 %}text-emerald-700{% else %}text-rose-700{% endif %}">{% if forecast.projected_change >= 0 %}+{% endif %}{{ forecast.projected_change }}kg total</div>
<div class="text-xs text-gray-500">{{ forecast.confidence }} confidence · {{ forecast.observed_points }} sessions</div>
</div>
</div>
{% if forecast.selected_top_set %}
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 rounded-lg border border-emerald-100 bg-white/80 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Projected top set</div>
<div class="text-lg font-bold text-gray-800">{{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }} reps</div>
</div>
<div class="text-right text-xs text-gray-500">
<div>Model range</div>
<div class="font-semibold text-emerald-700">{{ forecast.selected_top_set.lower_weight_display }}–{{ forecast.selected_top_set.upper_weight_display }}kg</div>
</div>
</div>
<div class="text-[11px] text-gray-500 sm:text-right">
Full rep-to-weight forecast in the table view.<br>
Based on {{ forecast.observed_rep_min }}–{{ forecast.observed_rep_max }} reps in view.
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg bg-white/80 px-2.5 py-2 text-sm">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Top-set guide</span>
<span class="font-bold text-gray-800">{{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }}</span>
<span class="text-xs text-gray-500">at the selected rep target · details in History</span>
</div>
{% endif %}
<div class="flex flex-wrap items-center gap-2 mt-3">
<div class="flex flex-wrap items-center gap-1.5 mt-2.5">
<span class="text-xs text-gray-500 mr-1">Horizon</span>
{% for weeks in [4, 8, 12, 26] %}
<button class="px-2.5 py-1 rounded-md text-xs font-medium {% if forecast_weeks == weeks %}bg-emerald-700 text-white{% else %}bg-white text-gray-600 border border-emerald-200 hover:bg-emerald-100{% endif %}"
{% for weeks, label in forecast_horizons %}
<button class="px-2 py-0.5 rounded-md text-xs font-medium {% if forecast_weeks == weeks %}bg-emerald-700 text-white{% else %}bg-white text-gray-600 border border-emerald-200 hover:bg-emerald-100{% endif %}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='true', forecast_weeks=weeks, forecast_reps=forecast_target_reps) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ weeks }}w</button>
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ label }}</button>
{% endfor %}
</div>
{% else %}
+4 -1
View File
@@ -29,7 +29,7 @@ def convert_str_to_date(date_str, format='%Y-%m-%d'):
def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'):
"""Build a conservative linear forecast from dated exercise observations."""
try:
horizon_weeks = max(4, min(int(horizon_weeks), 26))
horizon_weeks = max(4, min(int(horizon_weeks), 52))
except (TypeError, ValueError):
horizon_weeks = 8
@@ -133,6 +133,7 @@ def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'):
'last_date': last_date,
'last_value': round(float(observed_values[-1]), 1),
'projected_value': round(float(future_values[-1]), 1),
'projected_change': round(float(future_values[-1] - observed_values[-1]), 1),
'weekly_change': round(weekly_change, 1),
'r_squared': None if r_squared is None else round(r_squared, 2),
'confidence': confidence,
@@ -249,6 +250,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
observed_min_date, observed_max_date = min(start_dates), max(start_dates)
forecast = build_forecast(estimated_1rm, start_dates, forecast_weeks, metric='E1RM') if forecast_enabled else {'available': False}
forecast = add_top_set_forecast(forecast, weight, repetitions, forecast_target_reps)
forecast_horizons = [(4, '4w'), (8, '8w'), (12, '3m'), (26, '6m'), (52, '1y')]
chart_max_date = forecast.get('end_date', observed_max_date) if forecast.get('available') else observed_max_date
total_span = (chart_max_date - observed_min_date).days or 1
@@ -398,6 +400,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
'forecast_enabled': forecast_enabled,
'forecast_weeks': forecast_weeks,
'forecast_target_reps': forecast.get('selected_reps', forecast_target_reps or 5),
'forecast_horizons': forecast_horizons,
'forecast_points': forecast_points,
'forecast_band_lower': forecast_band_lower,
'forecast_band_upper': forecast_band_upper,