From cada28b54bb47e28dca5e4ef98216842d096da0d Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 6 Sep 2026 13:59:50 +1000 Subject: [PATCH] Fix forecast chart continuity and hover --- templates/partials/sparkline.html | 38 ++++++++++++++++++++++--------- utils.py | 17 +++++++++++--- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/templates/partials/sparkline.html b/templates/partials/sparkline.html index 6aab777..68e813d 100644 --- a/templates/partials/sparkline.html +++ b/templates/partials/sparkline.html @@ -19,8 +19,8 @@ {% macro path_band(lower_points, upper_points, vb_height) %} {% if lower_points and upper_points %} -M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int }} {{ (vb_height - value)+margin | int }}{% if not loop.last %} L{% endif %}{% endfor %} -{% for value, position in upper_points|reverse %} L{{ (position * vb_width)+margin | int }} {{ (vb_height - value)+margin | int }}{% endfor %} Z +M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | int) }} {{ (((vb_height - value) + margin) | int) }}{% if not loop.last %} L{% endif %}{% endfor %} +{% for value, position in upper_points|reverse %} L{{ (((position * vb_width) + margin) | int) }} {{ (((vb_height - value) + margin) | int) }}{% endfor %} Z {% endif %} {% endmacro %} @@ -30,7 +30,16 @@ M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int {% set y = (vb_height - value)+margin %} {% endfor %} -{% endmacro %} +{% endmacro %} + +{% macro forecast_circles(data_points, labels, color) %} +{% for value, position in data_points %} +{% set x = (position * vb_width)+margin %} +{% set y = (vb_height - value)+margin %} + +{% endfor %} +{% endmacro %} {% macro plot_line(points, color) %} @@ -224,10 +233,10 @@ M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int {% endif %} {% if forecast_enabled and forecast.available %} - - - {{ circles(forecast_points, '#059669') }} - + + + {{ forecast_circles(forecast_points, forecast_labels, '#059669') }} + {% endif %} {% for plot in plots %} @@ -236,15 +245,22 @@ M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int {% endfor %} - {% for pos, message in plot_labels %} + {% 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 %} - {% endfor %} - + class="pnt-{{ unique_id }}" data-msg="{{ message }}"> + {% endfor %} + {% if forecast_enabled and forecast.available %} + {% for pos, message in forecast_labels %} + {% set x = (pos * vb_width) - (stroke_width/2) + margin %} + + {% endfor %} + {% endif %} + diff --git a/utils.py b/utils.py index 7dfef02..8469c0c 100644 --- a/utils.py +++ b/utils.py @@ -84,10 +84,14 @@ def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'): horizon_days = horizon_weeks * 7 future_x = np.arange(0, horizon_days + 1, 7, dtype=float) + x[-1] future_dates = [last_date + timedelta(days=int(day - x[-1])) for day in future_x] - future_values = intercept + slope * future_x + # Anchor the projection to the most recent observed value. A pure + # regression line can sit noticeably above or below the latest workout, + # which makes the forecast appear to jump when it starts. + future_values = observed_values[-1] + slope * (future_x - x[-1]) - # A 95% prediction band, widened for points further from the observations. - prediction_error = 1.96 * residual_std * np.sqrt( + # A restrained 90% prediction band, widened for points further from the + # observations so the uncertainty remains readable on the small chart. + prediction_error = 1.645 * residual_std * np.sqrt( 1 + (1 / len(observed_values)) + ((future_x - mean_x) ** 2 / sum_squared_x) ) lower_values = np.maximum(future_values - prediction_error, 0) @@ -212,6 +216,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da forecast_points = [] forecast_band_lower = [] forecast_band_upper = [] + forecast_labels = [] forecast_start_position = None if forecast.get('available'): forecast_start_position = round((observed_max_date - observed_min_date).days / total_span, 3) @@ -220,6 +225,11 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da forecast_points.append((round(((point['value'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_band_lower.append((round(((point['lower'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_band_upper.append((round(((point['upper'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) + forecast_labels.append(( + position, + f"{point['date'].strftime('%d %b %y')} · Projected E1RM: {point['value']}kg " + f"(range {point['lower']}–{point['upper']}kg)" + )) # Return exercise data with SVG dimensions and data points return { @@ -243,6 +253,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da 'forecast_points': forecast_points, 'forecast_band_lower': forecast_band_lower, 'forecast_band_upper': forecast_band_upper, + 'forecast_labels': forecast_labels, 'forecast_start_position': forecast_start_position }