diff --git a/app.py b/app.py index b59e634..61fbc74 100644 --- a/app.py +++ b/app.py @@ -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)) diff --git a/routes/workout.py b/routes/workout.py index 43fe51e..0618b7f 100644 --- a/routes/workout.py +++ b/routes/workout.py @@ -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)) diff --git a/templates/partials/exercise_history.html b/templates/partials/exercise_history.html index 46278bc..6a62702 100644 --- a/templates/partials/exercise_history.html +++ b/templates/partials/exercise_history.html @@ -23,7 +23,7 @@ - {% 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

diff --git a/templates/partials/sparkline.html b/templates/partials/sparkline.html index b07ab94..2e9f621 100644 --- a/templates/partials/sparkline.html +++ b/templates/partials/sparkline.html @@ -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 %} +{% endif %} {% endfor %} {% endmacro %} @@ -102,9 +104,9 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |

- {% if best_fit_formula %} - {{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month - {% endif %} + {% 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 %}

{% for epoch in epochs %} @@ -127,43 +129,32 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | {% endfor %}
{% if forecast_enabled %} -
+
{% if forecast.available %} -
+
-
E1RM projection
-
{{ forecast.last_value }}kg now → {{ forecast.projected_value }}kg in {{ forecast.horizon_weeks }} weeks
+
Forecast · {{ forecast.horizon_weeks }} weeks
+
{{ forecast.projected_value }}kg projected E1RM
-
{% if forecast.weekly_change >= 0 %}+{% endif %}{{ forecast.weekly_change }}kg/week
+
{% if forecast.projected_change >= 0 %}+{% endif %}{{ forecast.projected_change }}kg total
{{ forecast.confidence }} confidence · {{ forecast.observed_points }} sessions
{% if forecast.selected_top_set %} -
-
-
-
Projected top set
-
{{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }} reps
-
-
-
Model range
-
{{ forecast.selected_top_set.lower_weight_display }}–{{ forecast.selected_top_set.upper_weight_display }}kg
-
-
-
- Full rep-to-weight forecast in the table view.
- Based on {{ forecast.observed_rep_min }}–{{ forecast.observed_rep_max }} reps in view. -
+
+ Top-set guide + {{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }} + at the selected rep target · details in History
{% endif %} -
+
Horizon - {% for weeks in [4, 8, 12, 26] %} - + hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ label }} {% endfor %}
{% else %} diff --git a/utils.py b/utils.py index 51fde2f..5787206 100644 --- a/utils.py +++ b/utils.py @@ -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,