Simplify forecast panel and extend horizon
This commit is contained in:
@@ -243,7 +243,7 @@ def get_exercise_progress_for_user(person_id, exercise_id):
|
|||||||
degree = request.args.get('degree', type=int, default=1)
|
degree = request.args.get('degree', type=int, default=1)
|
||||||
forecast_enabled = request.args.get('forecast', '').lower() in ('1', 'true', 'yes', 'on')
|
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 = 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)
|
forecast_target_reps = request.args.get('forecast_reps', type=int)
|
||||||
if forecast_target_reps is not None:
|
if forecast_target_reps is not None:
|
||||||
forecast_target_reps = max(1, min(forecast_target_reps, 20))
|
forecast_target_reps = max(1, min(forecast_target_reps, 20))
|
||||||
|
|||||||
+1
-1
@@ -303,7 +303,7 @@ def get_exercise_history(person_id, exercise_id):
|
|||||||
source_topset_id = request.args.get('source_topset_id', type=int)
|
source_topset_id = request.args.get('source_topset_id', type=int)
|
||||||
forecast_enabled = request.args.get('forecast', '').lower() in ('1', 'true', 'yes', 'on')
|
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 = 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)
|
forecast_target_reps = request.args.get('forecast_reps', type=int)
|
||||||
if forecast_target_reps is not None:
|
if forecast_target_reps is not None:
|
||||||
forecast_target_reps = max(1, min(forecast_target_reps, 20))
|
forecast_target_reps = max(1, min(forecast_target_reps, 20))
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
|
|||||||
|
|
||||||
{% macro forecast_circles(data_points, details, color) %}
|
{% macro forecast_circles(data_points, details, color) %}
|
||||||
{% for value, position in data_points %}
|
{% for value, position in data_points %}
|
||||||
|
{% if loop.last %}
|
||||||
{% set x = (position * vb_width)+margin %}
|
{% set x = (position * vb_width)+margin %}
|
||||||
{% set y = (vb_height - value)+margin %}
|
{% set y = (vb_height - value)+margin %}
|
||||||
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1.6" fill="{{ color }}" stroke="{{ color }}"
|
<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-e1rm="{{ details[loop.index0].e1rm }}"
|
||||||
data-topset="{{ details[loop.index0].top_set }}"
|
data-topset="{{ details[loop.index0].top_set }}"
|
||||||
data-range="{{ details[loop.index0].range }}"></circle>
|
data-range="{{ details[loop.index0].range }}"></circle>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
@@ -102,7 +104,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
|
<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
|
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -127,43 +129,32 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if forecast_enabled %}
|
{% 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 %}
|
{% 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>
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800">E1RM projection</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800">Forecast · {{ forecast.horizon_weeks }} weeks</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-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>
|
||||||
<div class="text-left sm:text-right">
|
<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 class="text-xs text-gray-500">{{ forecast.confidence }} confidence · {{ forecast.observed_points }} sessions</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if forecast.selected_top_set %}
|
{% 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="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">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Top-set guide</span>
|
||||||
<div>
|
<span class="font-bold text-gray-800">{{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }}</span>
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Projected top set</div>
|
<span class="text-xs text-gray-500">at the selected rep target · details in History</span>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
<span class="text-xs text-gray-500 mr-1">Horizon</span>
|
||||||
{% for weeks in [4, 8, 12, 26] %}
|
{% for weeks, label in forecast_horizons %}
|
||||||
<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 %}"
|
<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-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-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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -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'):
|
def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'):
|
||||||
"""Build a conservative linear forecast from dated exercise observations."""
|
"""Build a conservative linear forecast from dated exercise observations."""
|
||||||
try:
|
try:
|
||||||
horizon_weeks = max(4, min(int(horizon_weeks), 26))
|
horizon_weeks = max(4, min(int(horizon_weeks), 52))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
horizon_weeks = 8
|
horizon_weeks = 8
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@ def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'):
|
|||||||
'last_date': last_date,
|
'last_date': last_date,
|
||||||
'last_value': round(float(observed_values[-1]), 1),
|
'last_value': round(float(observed_values[-1]), 1),
|
||||||
'projected_value': round(float(future_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),
|
'weekly_change': round(weekly_change, 1),
|
||||||
'r_squared': None if r_squared is None else round(r_squared, 2),
|
'r_squared': None if r_squared is None else round(r_squared, 2),
|
||||||
'confidence': confidence,
|
'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)
|
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 = 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 = 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
|
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
|
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_enabled': forecast_enabled,
|
||||||
'forecast_weeks': forecast_weeks,
|
'forecast_weeks': forecast_weeks,
|
||||||
'forecast_target_reps': forecast.get('selected_reps', forecast_target_reps or 5),
|
'forecast_target_reps': forecast.get('selected_reps', forecast_target_reps or 5),
|
||||||
|
'forecast_horizons': forecast_horizons,
|
||||||
'forecast_points': forecast_points,
|
'forecast_points': forecast_points,
|
||||||
'forecast_band_lower': forecast_band_lower,
|
'forecast_band_lower': forecast_band_lower,
|
||||||
'forecast_band_upper': forecast_band_upper,
|
'forecast_band_upper': forecast_band_upper,
|
||||||
|
|||||||
Reference in New Issue
Block a user