From 7af0202c302de9b7176c24fe9bb819d63e505877 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 6 Sep 2026 14:30:23 +1000 Subject: [PATCH] Improve forecast readability and rep targets --- app.py | 5 +- db.py | 2 +- templates/partials/sparkline.html | 138 +++++++++++++++++++++--------- utils.py | 90 +++++++++++++++---- 4 files changed, 175 insertions(+), 60 deletions(-) diff --git a/app.py b/app.py index decec10..b59e634 100644 --- a/app.py +++ b/app.py @@ -244,8 +244,9 @@ def get_exercise_progress_for_user(person_id, exercise_id): 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_target_reps = request.args.get('forecast_reps', type=int, default=5) - forecast_target_reps = max(1, min(forecast_target_reps, 12)) + 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)) if epoch == 'Custom' and (min_date is None or max_date is None): (min_date, max_date) = db.get_exercise_earliest_and_latest_dates(person_id, exercise_id) diff --git a/db.py b/db.py index 26e988e..8b9b0f9 100644 --- a/db.py +++ b/db.py @@ -405,7 +405,7 @@ class DataBase(): def get_all_exercises(self): return self.exercises.get("") - def get_exercise_progress_for_user(self, person_id, exercise_id, min_date=None, max_date=None, epoch='all', degree=1, forecast_enabled=False, forecast_weeks=8, forecast_target_reps=5): + def get_exercise_progress_for_user(self, person_id, exercise_id, min_date=None, max_date=None, epoch='all', degree=1, forecast_enabled=False, forecast_weeks=8, forecast_target_reps=None): today = datetime.now() if epoch == '1M': min_date = today - relativedelta(months=1) diff --git a/templates/partials/sparkline.html b/templates/partials/sparkline.html index e0ebe94..abc3494 100644 --- a/templates/partials/sparkline.html +++ b/templates/partials/sparkline.html @@ -32,12 +32,17 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | {% endfor %} {% endmacro %} -{% macro forecast_circles(data_points, labels, color) %} +{% macro forecast_circles(data_points, details, color) %} {% for value, position in data_points %} {% set x = (position * vb_width)+margin %} {% set y = (vb_height - value)+margin %} + class="pnt-{{ unique_id }}" + data-kind="{{ details[loop.index0].kind }}" + data-date="{{ details[loop.index0].date }}" + data-e1rm="{{ details[loop.index0].e1rm }}" + data-topset="{{ details[loop.index0].top_set }}" + data-range="{{ details[loop.index0].range }}"> {% endfor %} {% endmacro %} @@ -53,15 +58,16 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | {% set parts = [random_int()] %} {% set unique_id = parts|join('-') %} -
- +

{{ title }}

@@ -137,24 +143,46 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
-
Estimated top set
-
{{ forecast.selected_top_set.weight }}kg × {{ forecast.selected_top_set.repetitions }}
+
Projected top set
+
{{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }} reps
-
Likely range
-
{{ forecast.selected_top_set.lower_weight }}–{{ forecast.selected_top_set.upper_weight }}kg
+
Model range
+
{{ forecast.selected_top_set.lower_weight_display }}–{{ forecast.selected_top_set.upper_weight_display }}kg
-
- Target reps - {% for reps in forecast.target_reps %} - - {% endfor %} +
+ Based on {{ forecast.observed_rep_min }}–{{ forecast.observed_rep_max }} reps in view, expanded by ±{{ forecast.rep_buffer }} reps. + Click a row to use that rep target in the chart hover.
-
Rounded to the nearest {{ forecast.target_weight_increment }}kg based on your recorded loads.
+
Rep-to-weight map
+
+ + + + + + + + + + + {% for top_set in forecast.top_sets %} + + + + + + + {% endfor %} + +
RepsEst. loadModel rangeProj. E1RM
{{ top_set.repetitions }}{{ top_set.weight_display }}kg{{ top_set.lower_weight_display }}–{{ top_set.upper_weight_display }}kg{{ top_set.e1rm_display }}kg
+
+
Loads rounded to the nearest {{ forecast.target_weight_increment_display }}kg from your recorded increments.
{% endif %}
@@ -259,7 +287,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | {% if forecast_enabled and forecast.available %} - {{ forecast_circles(forecast_points, forecast_labels, '#059669') }} + {{ forecast_circles(forecast_points, forecast_details, '#059669') }} {% endif %} {% for plot in plots %} @@ -268,28 +296,58 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | {% endfor %} - - {% for pos, message in plot_labels %} - {% set x = (pos * vb_width) - (stroke_width/2) + margin %} - {% set y = 0 %} - {% set width = stroke_width %} - {% set height = vb_height + margin %} - + + {% for detail in plot_details %} + {% set x = (detail.position * vb_width) - (stroke_width/2) + margin %} + {% set y = 0 %} + {% set width = stroke_width %} + {% set height = vb_height + margin %} + {% endfor %} {% if forecast_enabled and forecast.available %} - {% for pos, message in forecast_labels %} - {% set x = (pos * vb_width) - (stroke_width/2) + margin %} + {% for detail in forecast_details %} + {% set x = (detail.position * vb_width) - (stroke_width/2) + margin %} + class="pnt-{{ unique_id }}" + data-kind="{{ detail.kind }}" + data-date="{{ detail.date }}" + data-e1rm="{{ detail.e1rm }}" + data-topset="{{ detail.top_set }}" + data-range="{{ detail.range }}"> {% endfor %} {% endif %} - - -
+ + + +
{% for plot in plots %}
diff --git a/utils.py b/utils.py index aa3d728..51fde2f 100644 --- a/utils.py +++ b/utils.py @@ -9,6 +9,14 @@ from decimal import Decimal E1RM_BASE = 101.3 E1RM_REP_FACTOR = 2.67123 + + +def _format_kg(value): + """Format a load without noisy trailing zeroes.""" + numeric_value = float(value) + if numeric_value.is_integer(): + return str(int(numeric_value)) + return f"{numeric_value:.1f}".rstrip('0').rstrip('.') def convert_str_to_date(date_str, format='%Y-%m-%d'): try: @@ -162,6 +170,22 @@ def _round_weight(value, increment, direction='nearest'): return round(rounded, 2) +def infer_rep_targets(repetitions, buffer=5): + """Return an expanded rep range based on the currently visible history.""" + numeric_reps = sorted({int(round(float(value))) for value in repetitions if value is not None}) + if not numeric_reps: + return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5, None, None + + observed_min = min(numeric_reps) + observed_max = max(numeric_reps) + target_min = max(1, observed_min - buffer) + target_max = min(20, observed_max + buffer) + target_reps = list(range(target_min, target_max + 1)) + centered_reps = int(round(float(np.median(numeric_reps)))) + centered_reps = max(target_min, min(target_max, centered_reps)) + return target_reps, centered_reps, observed_min, observed_max + + def _top_set_estimates(e1rm, lower_e1rm, upper_e1rm, target_reps, increment): estimates = [] for repetitions in target_reps: @@ -171,29 +195,33 @@ def _top_set_estimates(e1rm, lower_e1rm, upper_e1rm, target_reps, increment): expected_weight = e1rm * conversion low_weight = lower_e1rm * conversion high_weight = upper_e1rm * conversion + expected = _round_weight(expected_weight, increment) + lower = _round_weight(low_weight, increment, 'floor') + upper = _round_weight(high_weight, increment, 'ceil') estimates.append({ 'repetitions': repetitions, - 'weight': _round_weight(expected_weight, increment), - 'lower_weight': _round_weight(low_weight, increment, 'floor'), - 'upper_weight': _round_weight(high_weight, increment, 'ceil') + 'weight': expected, + 'weight_display': _format_kg(expected), + 'lower_weight': lower, + 'lower_weight_display': _format_kg(lower), + 'upper_weight': upper, + 'upper_weight_display': _format_kg(upper), + 'e1rm_display': _format_kg(e1rm) }) return estimates -def add_top_set_forecast(forecast, historical_weights, target_reps=5): +def add_top_set_forecast(forecast, historical_weights, historical_repetitions, target_reps=None): """Attach projected reps x weight combinations to each forecast point.""" if not forecast.get('available'): return forecast + target_reps_list, centered_reps, observed_rep_min, observed_rep_max = infer_rep_targets(historical_repetitions) try: - selected_reps = max(1, min(int(target_reps), 12)) + selected_reps = centered_reps if target_reps is None else int(target_reps) except (TypeError, ValueError): - selected_reps = 5 - target_reps_list = [3, 5, 8, 10] - if selected_reps not in target_reps_list: - target_reps_list.append(selected_reps) - target_reps_list.sort() - + selected_reps = centered_reps + selected_reps = max(target_reps_list[0], min(target_reps_list[-1], selected_reps)) increment = infer_weight_increment(historical_weights) for point in forecast['points']: point['top_sets'] = _top_set_estimates( @@ -202,6 +230,10 @@ def add_top_set_forecast(forecast, historical_weights, target_reps=5): forecast['target_reps'] = target_reps_list forecast['target_weight_increment'] = increment + forecast['target_weight_increment_display'] = _format_kg(increment) + forecast['observed_rep_min'] = observed_rep_min + forecast['observed_rep_max'] = observed_rep_max + forecast['rep_buffer'] = 5 forecast['top_sets'] = forecast['points'][-1]['top_sets'] forecast['selected_reps'] = selected_reps forecast['selected_top_set'] = next( @@ -211,12 +243,12 @@ def add_top_set_forecast(forecast, historical_weights, target_reps=5): return forecast -def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_dates, messages, epoch, person_id, exercise_id, min_date=None, max_date=None, degree=1, forecast_enabled=False, forecast_weeks=8, forecast_target_reps=5): +def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_dates, messages, epoch, person_id, exercise_id, min_date=None, max_date=None, degree=1, forecast_enabled=False, forecast_weeks=8, forecast_target_reps=None): # Precompute ranges. Forecast values are included so the projected line and # confidence band have room inside the same SVG coordinate system. 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, forecast_target_reps) + forecast = add_top_set_forecast(forecast, weight, repetitions, forecast_target_reps) 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 @@ -294,11 +326,24 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da # Prepare plot labels plot_labels = list(zip(relative_positions.tolist(), messages)) + plot_details = [] + for position, point_date, point_e1rm, point_reps, point_weight in zip( + relative_positions.tolist(), start_dates, estimated_1rm, repetitions, weight + ): + plot_details.append({ + 'position': position, + 'date': point_date.strftime('%d %b %y'), + 'kind': 'Recorded top set', + 'e1rm': f"{_format_kg(point_e1rm)}kg", + 'top_set': f"{_format_kg(point_weight)}kg × {int(point_reps)} reps", + 'range': 'Recorded value' + }) forecast_points = [] forecast_band_lower = [] forecast_band_upper = [] forecast_labels = [] + forecast_details = [] forecast_start_position = None if forecast.get('available'): forecast_start_position = round((observed_max_date - observed_min_date).days / total_span, 3) @@ -314,9 +359,18 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da top_set_message = '' if selected_top_set: top_set_message = ( - f" · Est. {selected_top_set['weight']}kg × {selected_top_set['repetitions']} " - f"(range {selected_top_set['lower_weight']}–{selected_top_set['upper_weight']}kg)" + f" · Est. {selected_top_set['weight_display']}kg × {selected_top_set['repetitions']} " + f"(range {selected_top_set['lower_weight_display']}–{selected_top_set['upper_weight_display']}kg)" ) + forecast_details.append({ + 'position': position, + 'date': point['date'].strftime('%d %b %y'), + 'kind': 'Projected top set', + 'e1rm': f"{_format_kg(point['value'])}kg", + 'top_set': f"{selected_top_set['weight_display']}kg × {selected_top_set['repetitions']} reps", + 'range': f"{selected_top_set['lower_weight_display']}–{selected_top_set['upper_weight_display']}kg", + 'confidence': forecast['confidence'] + }) forecast_labels.append(( position, f"{point['date'].strftime('%d %b %y')} · Projected E1RM: {point['value']}kg " @@ -331,7 +385,8 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da 'plots': [repetitions_data, weight_data, estimated_1rm_data], 'best_fit_points': best_fit_points, 'best_fit_formula': best_fit_formula, - 'plot_labels': plot_labels, + 'plot_labels': plot_labels, + 'plot_details': plot_details, 'epochs': ['Custom', '1M', '3M', '6M', 'All'], 'selected_epoch': epoch, 'person_id': person_id, @@ -342,11 +397,12 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da 'forecast': forecast, 'forecast_enabled': forecast_enabled, 'forecast_weeks': forecast_weeks, - 'forecast_target_reps': forecast_target_reps, + 'forecast_target_reps': forecast.get('selected_reps', forecast_target_reps or 5), 'forecast_points': forecast_points, 'forecast_band_lower': forecast_band_lower, 'forecast_band_upper': forecast_band_upper, 'forecast_labels': forecast_labels, + 'forecast_details': forecast_details, 'forecast_start_position': forecast_start_position }