Compare commits

...
15 Commits
Author SHA1 Message Date
Peter Stockings 0c0bcdc4b6 Show consecutive stall count beside previous performance 2026-10-11 11:30:30 +11:00
Peter Stockings 9612d322eb Preserve observed progression steps in exercise forecasts 2026-09-06 22:46:07 +10:00
Peter Stockings dc0f41cd27 Integrate session note and tag editing and center progress controls 2026-09-06 22:38:11 +10:00
Peter Stockings d5e3e1d930 Reorganize workout view for desktop and compact session details 2026-09-06 17:10:06 +10:00
Peter Stockings 1aa5d7418b Only include custom date inputs when rendered 2026-09-06 16:45:37 +10:00
Peter Stockings 86fade54f1 Learn session forecasts from training blocks and clarify progress UI 2026-09-06 16:20:15 +10:00
Peter Stockings 499d859478 Anchor forecast top sets to latest rep target 2026-09-06 14:59:57 +10:00
Peter Stockings 50ddbced84 Show forecast point dates clearly 2026-09-06 14:55:09 +10:00
Peter Stockings 0dea94fec0 Simplify forecast panel and extend horizon 2026-09-06 14:50:51 +10:00
Peter Stockings d26da9a066 Move forecast map into exercise history 2026-09-06 14:38:30 +10:00
Peter Stockings 7af0202c30 Improve forecast readability and rep targets 2026-09-06 14:30:23 +10:00
Peter Stockings 499ecdc3c2 Add projected top set estimates 2026-09-06 14:12:48 +10:00
Peter Stockings cada28b54b Fix forecast chart continuity and hover 2026-09-06 13:59:50 +10:00
Peter Stockings e2c4196792 Add exercise progress forecasting 2026-09-06 13:53:23 +10:00
Peter Stockings d1100ece80 Add SQL insight dashboard widgets 2026-09-06 13:37:46 +10:00
36 changed files with 2198 additions and 475 deletions
+2 -1
View File
@@ -162,9 +162,10 @@ cython_debug/
# Exclude backup sql files
**/*.sql
!migrations/2026-09-06_add_dashboard_widgets.sql
# Exclude experimental juypter notebooks
**/*.ipynb
# Exclude node_modules
node_modules
node_modules
+19 -1
View File
@@ -112,6 +112,7 @@ def dashboard():
exercises = db.dashboard.get_exercises_with_selection(selected_people_ids, min_date, max_date, selected_exercise_ids)
tags = db.get_tags_for_dashboard()
dashboard = db.dashboard.get(selected_people_ids, min_date, max_date, selected_exercise_ids)
pinned_widgets = db.dashboard.get_pinned_widgets(current_user.id) if current_user.is_authenticated else []
# Render the appropriate response for HTMX or full page
render_args = {
@@ -119,6 +120,7 @@ def dashboard():
"people": people,
"exercises": exercises,
"tags": tags,
"pinned_widgets": pinned_widgets,
"selected_people_ids": selected_people_ids,
"max_date": max_date,
"min_date": min_date,
@@ -239,11 +241,27 @@ def get_exercise_progress_for_user(person_id, exercise_id):
'max_date'), '%Y-%m-%d')
epoch = request.args.get('epoch', default='All')
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, 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))
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)
exercise_progress = db.get_exercise_progress_for_user(person_id, exercise_id, min_date, max_date, epoch, degree=degree)
exercise_progress = db.get_exercise_progress_for_user(
person_id,
exercise_id,
min_date,
max_date,
epoch,
degree=degree,
forecast_enabled=forecast_enabled,
forecast_weeks=forecast_weeks,
forecast_target_reps=forecast_target_reps
)
if not exercise_progress:
abort(404)
+31 -13
View File
@@ -371,8 +371,8 @@ class DataBase():
WHERE
e.exercise_id = %s AND (w.person_id = %s OR w.person_id IS NULL)
ORDER BY
w.start_date DESC
LIMIT 1;
w.start_date DESC, t.topset_id DESC
LIMIT 1;
""", [exercise_id, person_id], one=True)
if not topset:
@@ -380,7 +380,7 @@ class DataBase():
else:
return (topset.get('repetitions'), topset.get('weight'), topset['exercise_name'])
def get_recent_topsets_for_exercise(self, person_id, exercise_id, limit=5, offset=0):
def get_recent_topsets_for_exercise(self, person_id, exercise_id, limit=5, offset=0, min_date=None, max_date=None):
topsets = self.execute("""
SELECT
t.topset_id,
@@ -394,18 +394,20 @@ class DataBase():
JOIN topset t ON e.exercise_id = t.exercise_id
JOIN workout w ON t.workout_id = w.workout_id
WHERE
e.exercise_id = %s AND w.person_id = %s
e.exercise_id = %s AND w.person_id = %s
AND (%s::date IS NULL OR w.start_date >= %s::date)
AND (%s::date IS NULL OR w.start_date <= %s::date)
ORDER BY
w.start_date DESC, t.topset_id DESC
LIMIT %s OFFSET %s;
""", [exercise_id, person_id, limit, offset])
""", [exercise_id, person_id, min_date, min_date, max_date, max_date, limit, offset])
return topsets
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):
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)
@@ -419,7 +421,7 @@ class DataBase():
SELECT
T.topset_id, E.name AS exercise_name, W.person_id, T.workout_id,
T.repetitions, T.weight,
ROUND((100 * T.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * T.repetitions), 0)::NUMERIC::INTEGER AS estimated_1rm,
ROUND((100 * T.weight::NUMERIC) / (101.3 - 2.67123 * T.repetitions), 1) AS estimated_1rm,
W.start_date
FROM
topset T
@@ -433,7 +435,7 @@ class DataBase():
(%s::date IS NULL OR W.start_date >= %s::date) AND
(%s::date IS NULL OR W.start_date <= %s::date)
ORDER BY
W.start_date;
W.start_date, T.topset_id;
""", [person_id, exercise_id, min_date, min_date, max_date, max_date])
# Return None if no topsets found
@@ -442,7 +444,7 @@ class DataBase():
# Extracting values and calculating value ranges for SVG dimensions
exercise_name = topsets[0]['exercise_name']
estimated_1rm = [t['estimated_1rm'] for t in topsets]
estimated_1rm = [float(t['estimated_1rm']) for t in topsets]
repetitions = [t['repetitions'] for t in topsets]
weight = [t['weight'] for t in topsets]
start_dates = [t['start_date'] for t in topsets]
@@ -453,7 +455,19 @@ class DataBase():
latest_topset_id = latest_topset['topset_id']
latest_workout_id = latest_topset['workout_id']
exercise_progress = get_exercise_graph_model(
# View controls affect the chart/rep map, not training. Respect the
# visible cutoff so custom historical views never train on later sets.
forecast_training_rows = None
if forecast_enabled:
forecast_training_rows = self.execute("""
SELECT w.start_date, t.weight, t.repetitions
FROM topset t JOIN workout w USING(workout_id)
WHERE w.person_id = %s AND t.exercise_id = %s
AND w.start_date <= %s
ORDER BY w.start_date, t.topset_id
""", [person_id, exercise_id, max(start_dates)])
exercise_progress = get_exercise_graph_model(
exercise_name,
estimated_1rm,
repetitions,
@@ -463,9 +477,13 @@ class DataBase():
epoch,
person_id,
exercise_id,
min_date,
max_date,
degree)
min_date,
max_date,
degree,
forecast_enabled,
forecast_weeks,
forecast_target_reps,
forecast_training_rows=forecast_training_rows)
exercise_progress['latest_topset_id'] = latest_topset_id
exercise_progress['latest_workout_id'] = latest_workout_id
+146
View File
@@ -0,0 +1,146 @@
# Exercise forecasts
The forecast predicts joint **load × reps** outcomes for an exercise's next
sessions. E1RM is derived from those sets with fractional loads preserved. The
graph and table use the same forecast. Equivalent loads at other rep counts
are a separate, collapsed reference table, not predicted workout choices.
## Why this model
A small, recency-weighted transition model fits the data and the user workflow:
repeated sets, rep gains, difficult sessions, then load increases and rep resets.
It is a nonparametric learner (weighted neighbours), trained on demand on the
current person/exercise block. It needs only NumPy; no external training service,
cross-person data pooling or large ML dependency is involved.
We compared it with repeating the last set, an anchored full-block linear E1RM
trend, and a damped recent E1RM trend. A larger neural/boosted model was not
trained: the per-exercise histories are small, and this model already improves
the held-out metrics while preserving interpretable load/rep transitions.
The evaluation follows [rolling-origin validation](https://otexts.com/fpp3/tscv.html):
fit only on observations strictly before the held-out session. Random train/test
splits can give [optimistic results for lagged time series](https://scikit-learn.org/stable/auto_examples/applications/plot_time_series_lagged_features.html).
[Damping](https://otexts.com/fpp3/holt.html) motivates restraining long-horizon growth;
our load-transition damping is a conservative scenario assumption, not a learned
physiological law.
## Training breaks and data handling
- Take the best E1RM set per date, including deterministic ties. Duplicate sets
do not increase the session count.
- Split training blocks online when a gap exceeds three recent usual intervals,
with a 28-day minimum, 84-day maximum and 42-day sparse-history fallback.
The threshold uses only preceding gaps; cadence restarts after a break.
- Fit the latest block only. Require four valid sessions after a break. Older
blocks remain visible in the chart but cannot drive current forecasts.
- History-range controls change the chart and equivalent-rep map, not the
training window. A custom end date is also a strict training cutoff.
- Positive loads and integer reps 1–20 are supported. Missing, invalid and
unsupported readings do not train the model. The UI explains when a new
forecast is unavailable. This E1RM model is not appropriate for timed carries,
zero-load bodyweight work, or other metrics merely stored as reps.
- Old histories are labelled historical projections, not upcoming workouts.
## Model details
The last 32 current-block sessions supply joint load/rep transitions. A kernel
weights their similarity to the current reps, relative load and stall streak;
recency has an eight-session half-life. An explicit repeat-set prior keeps sparse
evidence conservative. A stall means failing to improve the rep best at the
current load, including drops/recoveries rather than counting every rebound as
new progress.
The usual pre-increase reps and most common load increment are inferred
from the recent 24 sessions. The model preserves the observed mix of load
increments from its transition window rather than replacing every increase with
the most common one. Jumps over 15% of their original source load are excluded
from progression evidence. Load increases are allowed at the inferred threshold;
their reset reps come from actual transitions. No fixed 8–12 prescription is
hardcoded. If there are no observed increases, the model cannot invent them.
Rep-building transitions are excluded at the ceiling, so clipping a rep gain
cannot turn it into artificial evidence of a stall. Actual repeats and the
explicit repeat-set prior remain in the model.
The next set is the most likely **joint** outcome. For longer horizons, 512
deterministically seeded trajectories produce scenario sets at the median load
and median reps conditional on that load. This avoids jumping between dissimilar
sets just because their E1RMs match. Each row describes a representative outcome
at that date, not one prescribed sequence. Dates use the median recent session
gap. A horizon extending between workout dates carries forward the previous
scenario without inventing another workout.
The band starts at the observed value and uses simulated 10th/90th E1RM quantiles,
broadened to at least the 80th percentile of recent absolute one-session backtest
errors. These are plausible scenario ranges, **not calibrated 80% confidence
intervals**. Long-horizon growth probabilities are damped after 12 sessions.
Intervals and one-year scenarios have not been prospectively validated.
## Production audit, 6 September 2026
Read-only SSH/SQL inspection of Peter's 3,050 recorded sets covered 132 exercises.
There were 3,015 distinct valid exercise/date observations and 53 detected breaks;
91 latest blocks had at least four usable sessions. The rest are withheld with
an explanation. Old but sufficiently populated blocks can produce historical
projections; this count is not a count of currently active exercises.
Every eligible historical block was tested, starting with eight training sessions.
Holdouts never cross a detected break. The first four to eight post-break sessions
therefore have less validated support. All methods use the same block segmentation,
fractional loads and estimated session cadence. Linear/damped baselines keep the
latest rep target, as the previous product did. These are retrospective development
results, not independent prospective validation or a guarantee for every exercise.
| Horizon | Holdouts | Model | Load MAE (kg) | Rep MAE | E1RM MAE (kg) |
| --- | ---: | --- | ---: | ---: | ---: |
| Next session | 1,929 | Learned transitions | 0.272 | 0.687 | 1.863 |
| Next session | 1,929 | Repeat last set | 0.506 | 0.800 | 2.395 |
| Next session | 1,929 | Damped trend | 0.374 | 0.800 | 2.133 |
| Next session | 1,929 | Linear trend | 0.422 | 0.800 | 2.118 |
| 4 sessions | 1,636 | Learned transitions | 0.953 | 1.030 | 3.112 |
| 4 sessions | 1,636 | Repeat last set | 1.884 | 1.145 | 4.159 |
| 4 sessions | 1,636 | Damped trend | 1.074 | 1.145 | 3.436 |
| 4 sessions | 1,636 | Linear trend | 1.201 | 1.145 | 3.581 |
| 8 sessions | 1,308 | Learned transitions | 1.716 | 1.173 | 4.151 |
| 8 sessions | 1,308 | Repeat last set | 3.545 | 1.158 | 5.906 |
| 8 sessions | 1,308 | Damped trend | 1.903 | 1.158 | 4.518 |
| 8 sessions | 1,308 | Linear trend | 2.056 | 1.158 | 4.845 |
The learned model improves aggregate load and E1RM errors at all tested horizons.
At eight sessions it does **not** beat persistence on reps; difficult sessions
remain unpredictable. The UI includes exercise-specific recent error metrics.
After correcting the collapsed load increments and artificial ceiling repeats,
next-session and four-session errors improved over the original transition model.
Eight-session load error improved from 1.782 to 1.716 kg, but E1RM error rose
slightly from 4.114 to 4.151 kg. This adjustment is not an accuracy gain at every
horizon. All 23 unit tests pass, including mixed increments, ceiling transitions,
outlier increases and unchanged plateau behaviour.
Peter's Stiff Leg Deadlift (TNG) eight-week scenario changes from 181 kg × 7 to
183 kg × 6, and its 26-week scenario from 183.5 kg × 7 to 188 kg × 6. These are
joint load/rep outcomes: a higher load does not necessarily mean a higher E1RM
at every horizon. DB Bulgarian Split Squat's 26-week historical scenario changes
from 39 kg × 10 to 39.5 kg × 11; its history ends on 11 January 2024. The current
Smith machine Bulgarian split squat has only two sessions and remains withheld
until four valid sessions are available.
For 30 degrees Smith Press, the latest block has 39 sessions. The current load
block contains seven sessions at 93.5 kg, progressing 4→5→6→6→6→6→7 reps.
Three sessions did not improve the rep best. The inferred threshold is nine reps
and the load increment is 0.5 kg. The next forecast is 93.5 kg × 7, around 13 Sep
2026 at an eight-day cadence. Its E1RM is 113.2 kg; the old SQL rounded the load
to 94 kg and then the E1RM to 114 kg.
## Reproduce
```powershell
.venv/Scripts/python -m unittest discover -s tests -v
.venv/Scripts/python scripts/audit_forecasts.py --person Peter
.venv/Scripts/python scripts/preview_forecasts.py
```
The audit and local preview keep raw production records in process memory.
Neither modifies the database. Cached forecasts are bounded, keyed by the full
training rows and horizon, and copied before rendering; new records invalidate
the key naturally.
+51 -4
View File
@@ -1,9 +1,56 @@
from utils import calculate_estimated_1rm, get_exercise_graph_model
from utils import calculate_estimated_1rm, get_exercise_graph_model, prepare_svg_plot_data
class Dashboard:
def __init__(self, db_connection_method):
self.execute = db_connection_method
class Dashboard:
def __init__(self, db_connection_method):
self.execute = db_connection_method
def get_pinned_widgets(self, person_id):
"""Execute and prepare the current user's pinned SQL dashboard widgets."""
widgets = self.execute("""
SELECT
w.widget_id,
w.saved_query_id AS query_id,
w.visualization_type,
q.title,
q.query
FROM dashboard_widget w
INNER JOIN saved_query q ON q.id = w.saved_query_id
WHERE w.person_id = %s AND w.is_visible = TRUE
ORDER BY w.position, w.widget_id
""", [person_id]) or []
prepared_widgets = []
for widget in widgets:
prepared = dict(widget)
prepared['results'] = []
prepared['columns'] = []
prepared['row_count'] = 0
prepared['plot_data'] = None
prepared['error'] = None
try:
results = self.execute(widget['query']) or []
columns = list(results[0].keys()) if results else []
prepared['row_count'] = len(results)
prepared['results'] = results[:100]
prepared['columns'] = columns
if widget['visualization_type'] == 'graph' and results:
prepared['plot_data'] = prepare_svg_plot_data(
results[:100], columns, widget['title']
)
except Exception as error:
# A broken saved query should not take down the Home page.
try:
self.execute('ROLLBACK')
except Exception:
pass
prepared['error'] = str(error)
prepared_widgets.append(prepared)
return prepared_widgets
def get_people_ids(self):
query = """
+327
View File
@@ -0,0 +1,327 @@
"""Small-data, session-based top-set forecasts; no future observations in fitting.
The empirical transition model is a recency-weighted nearest-neighbour model.
It learns joint load/rep changes, including repeats and regressions, rather than
converting a fitted E1RM line into an alleged workout. See docs/forecasting.md.
"""
from datetime import date, datetime, timedelta
from functools import lru_cache
from collections import Counter
import math
import numpy as np
BASE = 101.3
REP_FACTOR = 2.67123
def e1rm(weight, reps):
return np.asarray(weight) * 100 / (BASE - REP_FACTOR * np.asarray(reps))
def sessions(dates, weights, reps):
"""One best set per date, with deterministic ties and fractional loads intact."""
by_date = {}
for day, weight, repetitions in zip(dates, weights, reps):
if isinstance(day, datetime):
day = day.date()
try:
weight, repetitions = float(weight), float(repetitions)
except (TypeError, ValueError):
continue
if (not isinstance(day, date) or not math.isfinite(weight)
or not math.isfinite(repetitions) or weight <= 0
or not repetitions.is_integer() or not 1 <= repetitions <= 20):
continue
row = (day, weight, int(repetitions))
if day not in by_date or (float(e1rm(weight, repetitions)), weight, repetitions) > (
float(e1rm(by_date[day][1], by_date[day][2])), *by_date[day][1:]):
by_date[day] = row
return tuple(sorted(by_date.values()))
def profile(rows):
"""Summarise the recent training regime without counting recovery as a PR."""
recent = rows[-24:]
weights = np.array([r[1] for r in recent])
reps = np.array([r[2] for r in recent])
gaps = np.array([(b[0] - a[0]).days for a, b in zip(recent, recent[1:])])
dw = np.diff(weights)
increases = dw[(dw > 0.01) & (dw <= weights[:-1] * 0.15)]
# Use an observed increment, not an average that invents plate sizes.
increment = Counter(np.round(increases, 3)).most_common(1)[0][0] if len(increases) else 0.0
ceiling_samples = reps[:-1][(dw > 0.01) & (dw <= weights[:-1] * 0.15)]
ceiling = int(round(float(np.median(ceiling_samples)))) if len(ceiling_samples) >= 2 else int(max(reps))
block = [rows[-1]]
for row in reversed(rows[:-1]):
if abs(row[1] - weights[-1]) > 0.01:
break
block.insert(0, row)
best, stalls, run = block[0][2], 0, 0
for row in block[1:]:
if row[2] > best:
best, run = row[2], 0
else:
stalls, run = stalls + 1, run + 1
return {
'increment': increment, 'ceiling': ceiling,
'cadence_days': max(1, int(round(float(np.median(gaps[-12:]))))),
'block_sessions': len(block), 'block_stalls': stalls, 'stall_run': run,
'block_start': block[0][0], 'block_start_reps': block[0][2],
'block_best_reps': best, 'recent_sessions': len(recent),
'recent_load_repeats': int(np.sum(abs(dw) < 0.01)),
'transitions': len(dw), 'ceiling_evidence': len(ceiling_samples),
}
def training_blocks(rows):
"""Detect layoffs online using only cadence known before each gap.
A gap exceeds three usual intervals (at least 28, at most 84 days).
Sparse starts use 42 days. The cadence restarts with each new block.
"""
blocks = []
gaps = []
for row in rows:
if not blocks:
blocks.append([row])
continue
gap = (row[0] - blocks[-1][-1][0]).days
threshold = max(28, min(84, 3 * float(np.median(gaps[-8:])))) if len(gaps) >= 3 else 42
if gap > threshold:
blocks.append([row])
gaps = []
else:
blocks[-1].append(row)
gaps.append(gap)
return tuple(tuple(block) for block in blocks)
class TransitionModel:
"""Kernel-smoothed joint transitions with an explicit repeat-set prior."""
def __init__(self, rows):
self.rows = rows
self.profile = profile(rows)
recent = rows[-32:]
w = np.array([r[1] for r in recent])
r = np.array([r[2] for r in recent])
gap = np.array([(b[0] - a[0]).days for a, b in zip(recent, recent[1:])])
# A long layoff starts a different training block, not an ordinary step.
keep = gap <= max(28, self.profile['cadence_days'] * 3)
self.source_w, self.source_r = w[:-1][keep], r[:-1][keep]
self.dw = np.diff(w)[keep]
self.dr = np.diff(r)[keep]
self.reset_r = r[1:][keep]
self.age = np.arange(len(w) - 2, -1, -1)[keep]
stall_runs, run, best, load = [], 0, int(r[0]), w[0]
for index, (weight, reps) in enumerate(zip(w[:-1], r[:-1])):
if abs(weight - load) > 0.01:
load, best, run = weight, int(reps), 0
elif reps > best:
best, run = int(reps), 0
elif index:
run += 1
stall_runs.append(run)
self.stalls = np.array(stall_runs)[keep]
def outcomes(self, weights, reps, stalls, step=1):
weights, reps, stalls = map(np.atleast_1d, (weights, reps, stalls))
# Learn from nearby rep counts, loads and stall lengths. Recent sessions
# have greater influence; the current long plateau outweighs old PRs.
distance = (abs(reps[:, None] - self.source_r) / 2
+ abs(np.log(weights[:, None] / self.source_w)) / 0.15
+ abs(np.minimum(stalls[:, None], 5) - np.minimum(self.stalls, 5)) / 4)
probabilities = np.exp(-distance) * 0.5 ** (self.age / 8)
increase = self.dw > 0.01
decrease = self.dw < -0.01
# Preserve the observed mix of increments; the mode alone can turn
# alternating small/large plate jumps into permanently tiny steps.
ordinary_increase = self.dw <= self.source_w * 0.15
probabilities[:, increase & ~ordinary_increase] = 0
probabilities[:, increase] *= (reps >= self.profile['ceiling'])[:, None]
# A rep-building transition is not evidence of stalling at the ceiling.
# Exclude it there instead of clipping its gain into an artificial repeat.
rep_gain = (abs(self.dw) < 0.01) & (self.dr > 0)
probabilities[:, rep_gain] *= (reps < self.profile['ceiling'])[:, None]
# Calendar horizons beyond ~12 sessions are scenarios. Damp the rate of
# further load advances rather than extrapolating beginner gains forever.
probabilities[:, increase] *= 0.98 ** max(0, step - 12)
delta_w = self.dw
delta_w = np.maximum(delta_w, -weights[:, None] * 0.15)
out_w = np.maximum(weights[:, None] + delta_w, 0.5)
out_r = np.clip(reps[:, None] + self.dr, 1, 20)
out_r[:, increase | decrease] = np.clip(self.reset_r[increase | decrease], 1, 20)
# Rep gains below a learned ceiling, not unlimited rep inflation.
out_r = np.where((self.dw == 0) & (self.dr > 0),
np.minimum(out_r, np.maximum(reps[:, None], self.profile['ceiling'])), out_r)
probabilities = np.column_stack([np.ones(len(weights)), probabilities])
out_w = np.column_stack([weights, out_w])
out_r = np.column_stack([reps, out_r])
probabilities /= probabilities.sum(axis=1, keepdims=True)
return np.round(out_w, 3), out_r.astype(int), probabilities
def next_set(self):
w, r, p = self.outcomes(self.rows[-1][1], self.rows[-1][2], self.profile['stall_run'])
# Combine identical outcomes before choosing the most likely joint set.
outcomes = {}
for weight, reps, probability in zip(w[0], r[0], p[0]):
key = (float(weight), int(reps))
outcomes[key] = outcomes.get(key, 0) + float(probability)
ranked = sorted(outcomes.items(), key=lambda item: -item[1])
return ranked
def simulate(self, steps, paths=512):
rng = np.random.default_rng(20260906)
weights = np.full(paths, self.rows[-1][1], dtype=float)
reps = np.full(paths, self.rows[-1][2], dtype=int)
stalls = np.full(paths, self.profile['stall_run'], dtype=int)
best_reps = np.full(paths, self.profile['block_best_reps'], dtype=int)
result = [(weights.copy(), reps.copy())]
for step in range(1, steps + 1):
ow, oreps, p = self.outcomes(weights, reps, stalls, step)
choices = (rng.random(paths)[:, None] > np.cumsum(p, axis=1)).sum(axis=1)
choices = np.minimum(choices, p.shape[1] - 1)
nw, nr = ow[np.arange(paths), choices], oreps[np.arange(paths), choices]
changed = abs(nw - weights) > 0.01
stalls = np.where(changed | (nr > best_reps), 0, stalls + 1)
best_reps = np.where(changed, nr, np.maximum(best_reps, nr))
weights, reps = nw, nr
result.append((weights.copy(), reps.copy()))
return result
def benchmark(rows, method, steps=1):
"""Forecast-only baselines: repeat last set or a damped recent E1RM trend."""
weight, reps = rows[-1][1:]
if method == 'repeat':
return weight, reps
sample = rows if method == 'linear' else rows[-12:]
x = np.array([(r[0] - sample[0][0]).days for r in sample])
y = e1rm([r[1] for r in sample], [r[2] for r in sample])
slope = float(np.polyfit(x, y, 1)[0])
days = profile(rows)['cadence_days']
advance = steps if method == 'linear' else sum(0.9 ** i for i in range(1, steps + 1))
value = max(0, float(y[-1]) + slope * days * advance)
load = value * (BASE - REP_FACTOR * reps) / 100
increment = profile(rows)['increment'] or 0.5
return max(0.5, round(load / increment) * increment), reps
def backtest(rows, limit=20):
"""Expanding-window one-session holdouts. No random train/test split."""
errors = {name: [] for name in ('transitions', 'repeat', 'damped', 'linear')}
for end in range(max(8, len(rows) - limit), len(rows)):
train, actual = rows[:end], rows[end]
if (actual[0] - train[-1][0]).days > max(28, profile(train)['cadence_days'] * 3):
continue
predicted = {'transitions': TransitionModel(train).next_set()[0][0]}
predicted.update({name: benchmark(train, name) for name in errors if name != 'transitions'})
for name, (weight, reps) in predicted.items():
errors[name].append((abs(weight - actual[1]), abs(reps - actual[2]),
abs(float(e1rm(weight, reps) - e1rm(actual[1], actual[2])))))
return {name: {'count': len(values),
'weight_mae': round(float(np.mean(values, axis=0)[0]), 2),
'reps_mae': round(float(np.mean(values, axis=0)[1]), 2),
'e1rm_mae': round(float(np.mean(values, axis=0)[2]), 2),
'error_p80': round(float(np.quantile(np.array(values)[:, 2], .8)), 2)}
for name, values in errors.items() if values}
def _point(day, weight, reps, lower, upper, session_index):
return {'date': day, 'value': round(float(e1rm(weight, reps)), 1),
'lower': round(float(lower), 1), 'upper': round(float(upper), 1),
'projected_weight': float(weight), 'projected_repetitions': int(reps),
'set_display': f'{weight:g} kg × {int(reps)}', 'session': session_index}
def representative_set(weights, reps):
"""Median load, then conditional median reps at that load.
Picking by E1RM alone can jump between distant load/rep combinations with
almost identical strength. Use the actual joint load/rep distribution.
"""
loads = np.unique(weights)
weight = float(loads[np.argmin(abs(loads - np.median(weights)))])
conditional = reps[weights == weight]
observed_reps = np.unique(conditional)
repetitions = int(observed_reps[np.argmin(abs(observed_reps - np.median(conditional)))])
return weight, repetitions
@lru_cache(maxsize=128)
def _forecast(rows, horizon_weeks):
p = profile(rows)
model = TransitionModel(rows)
cadence = p['cadence_days']
steps = math.ceil(horizon_weeks * 7 / cadence)
simulations = model.simulate(steps)
validation = backtest(rows)
# Keep the displayed scenario band at least as broad as recent held-out
# errors. This is an empirical safeguard, not a calibrated 80% guarantee.
error_floor = validation.get('transitions', {}).get('error_p80', 0)
points = []
for index, (weights, reps) in enumerate(simulations):
if index * cadence > horizon_weeks * 7:
break
values = e1rm(weights, reps)
lower, median, upper = np.quantile(values, [0.1, 0.5, 0.9])
if index:
lower, upper = max(0, min(lower, median - error_floor)), max(upper, median + error_floor)
weight, repetitions = representative_set(weights, reps)
points.append(_point(rows[-1][0] + timedelta(days=index * cadence), weight, repetitions,
min(lower, float(e1rm(weight, repetitions))),
max(upper, float(e1rm(weight, repetitions))), index))
# Extend the chart to the selected calendar horizon without inventing a
# workout between the learned session dates.
end_date = rows[-1][0] + timedelta(weeks=horizon_weeks)
chart_points = list(points)
if points[-1]['date'] != end_date:
chart_points.append(dict(points[-1], date=end_date))
ranking = model.next_set()
(weight, repetitions), probability = ranking[0]
next_values = e1rm(simulations[1][0], simulations[1][1])
next_point = _point(rows[-1][0] + timedelta(days=cadence), weight, repetitions,
min(np.quantile(next_values, .1), e1rm(weight, repetitions)),
max(np.quantile(next_values, .9), e1rm(weight, repetitions)), 1)
next_point['lower'] = round(max(0, min(next_point['lower'], next_point['value'] - error_floor)), 1)
next_point['upper'] = round(max(next_point['upper'], next_point['value'] + error_floor), 1)
if len(points) > 1:
points[1].update(next_point)
return {
'available': True, 'metric': 'E1RM', 'model': 'transitions',
'horizon_weeks': horizon_weeks, 'observed_points': len(rows),
'last_date': rows[-1][0], 'last_value': points[0]['value'],
'projected_value': points[-1]['value'],
'projected_change': round(points[-1]['value'] - points[0]['value'], 1),
'weekly_change': round((points[-1]['value'] - points[0]['value']) / horizon_weeks, 1),
'end_date': end_date, 'points': chart_points, 'session_points': points[1:],
'next_session': next_point, 'profile': p, 'validation': validation,
'alternatives': [{'weight': w, 'reps': r, 'probability': round(prob * 100)}
for (w, r), prob in ranking[:3]],
'long_range': horizon_weeks > 12,
}
def build_forecast(values, dates, horizon_weeks=8, metric='E1RM', weights=None, repetitions=None):
# Copy the cached result because the rendering layer adds equivalent sets.
from copy import deepcopy
try:
horizon_weeks = max(4, min(52, int(horizon_weeks)))
except (TypeError, ValueError):
horizon_weeks = 8
rows = sessions(dates, weights if weights is not None else [],
repetitions if repetitions is not None else [])
if len(rows) < 4:
return {'available': False, 'reason': 'Add at least 4 distinct workout dates with valid sets (1–20 reps).'}
dated = [d.date() if isinstance(d, datetime) else d for d in dates if isinstance(d, date)]
if dated and max(dated) > rows[-1][0]:
return {'available': False, 'reason': 'The latest reading needs a positive load and 1–20 reps for this forecast.'}
blocks = training_blocks(rows)
rows = blocks[-1]
if len(rows) < 4:
return {'available': False, 'reason': 'Add 4 sessions after the recent training break to learn the new pattern.'}
result = deepcopy(_forecast(rows, horizon_weeks))
result['training_blocks'] = len(blocks)
result['training_start'] = rows[0][0]
result['stale'] = (date.today() - rows[-1][0]).days > max(28, result['profile']['cadence_days'] * 3)
return result
@@ -0,0 +1,26 @@
-- Saved SQL insights can be pinned as live widgets on the home dashboard.
-- Keep the existing saved_query table compatible with older installations.
CREATE TABLE IF NOT EXISTS saved_query (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
query TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS dashboard_widget (
widget_id SERIAL PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES person(person_id) ON DELETE CASCADE,
saved_query_id INTEGER NOT NULL REFERENCES saved_query(id) ON DELETE CASCADE,
visualization_type VARCHAR(20) NOT NULL DEFAULT 'table',
position INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 1,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT dashboard_widget_visualization_type_chk
CHECK (visualization_type IN ('table', 'graph')),
CONSTRAINT dashboard_widget_unique_view
UNIQUE (person_id, saved_query_id, visualization_type)
);
CREATE INDEX IF NOT EXISTS dashboard_widget_person_position_idx
ON dashboard_widget (person_id, position, widget_id);
+3 -3
View File
@@ -84,7 +84,7 @@ def get_person_notes(person_id):
def get_workout_note_edit_form(person_id, workout_id):
"""Returns the form to edit a specific workout note."""
note = _fetch_workout_note(person_id, workout_id) # Use local helper
return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note, is_edit=True)
return render_template('partials/session_note.html' if request.args.get('view') == 'session' else 'partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note, is_edit=True)
@notes_bp.route("/person/<int:person_id>/workout/<int:workout_id>/note", methods=['PUT'])
@validate_workout
@@ -93,11 +93,11 @@ def update_workout_note(person_id, workout_id):
note = request.form.get('note')
_update_workout_note_for_person(person_id, workout_id, note) # Use local helper
db.activityRequest.log(current_user.id, 'UPDATE_NOTE', 'workout', workout_id, f"Updated note for workout {workout_id}")
return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
return render_template('partials/session_note.html' if request.args.get('view') == 'session' else 'partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
@notes_bp.route("/person/<int:person_id>/workout/<int:workout_id>/note", methods=['GET'])
@validate_workout
def get_workout_note(person_id, workout_id):
"""Returns the display partial for a specific workout note."""
note = _fetch_workout_note(person_id, workout_id) # Use local helper
return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
return render_template('partials/session_note.html' if request.args.get('view') == 'session' else 'partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
+64 -7
View File
@@ -65,18 +65,75 @@ def _save_query(title, query):
db.getDB().rollback()
return error
def _list_saved_queries():
"""Lists all saved queries."""
return db.execute("SELECT id, title, query FROM saved_query ORDER BY title")
def _list_saved_queries():
"""Lists all saved queries."""
person_id = getattr(current_user, 'id', None) or -1
return db.execute("""
SELECT
q.id,
q.title,
q.query,
EXISTS (
SELECT 1 FROM dashboard_widget w
WHERE w.saved_query_id = q.id
AND w.person_id = %s
AND w.visualization_type = 'table'
AND w.is_visible = TRUE
) AS table_pinned,
EXISTS (
SELECT 1 FROM dashboard_widget w
WHERE w.saved_query_id = q.id
AND w.person_id = %s
AND w.visualization_type = 'graph'
AND w.is_visible = TRUE
) AS graph_pinned
FROM saved_query q
ORDER BY q.title
""", [person_id, person_id])
def _get_saved_query(query_id):
"""Fetches a specific saved query."""
result = db.execute("SELECT title, query FROM saved_query WHERE id=%s", [query_id], one=True)
return (result['title'], result['query']) if result else (None, None)
def _delete_saved_query(query_id):
"""Deletes a saved query."""
db.execute("DELETE FROM saved_query WHERE id=%s", [query_id], commit=True)
def _delete_saved_query(query_id):
"""Deletes a saved query."""
db.execute("DELETE FROM saved_query WHERE id=%s", [query_id], commit=True)
@sql_explorer_bp.route('/widget/<int:query_id>', methods=['POST', 'DELETE'])
@login_required
def dashboard_widget(query_id):
"""Pin or unpin a saved query as a personal Home dashboard widget."""
visualization_type = request.values.get('visualization_type', 'table')
if visualization_type not in ('table', 'graph'):
return "Unsupported visualization type", 400
query = db.execute("SELECT id FROM saved_query WHERE id=%s", [query_id], one=True)
if not query:
return "Query not found", 404
if request.method == 'POST':
db.execute("""
INSERT INTO dashboard_widget (person_id, saved_query_id, visualization_type)
VALUES (%s, %s, %s)
ON CONFLICT (person_id, saved_query_id, visualization_type)
DO UPDATE SET is_visible = TRUE
""", [current_user.id, query_id, visualization_type], commit=True)
else:
db.execute("""
DELETE FROM dashboard_widget
WHERE person_id=%s AND saved_query_id=%s AND visualization_type=%s
""", [current_user.id, query_id, visualization_type], commit=True)
# Home widgets target themselves, while SQL Explorer refreshes its library.
if request.headers.get('HX-Target', '').startswith('dashboard-widget-'):
return ''
return render_template(
'partials/sql_explorer/saved_queries.html',
saved_queries=_list_saved_queries()
)
def _generate_sql_from_natural_language(natural_query):
"""Generates SQL query from natural language using Gemini REST API."""
@@ -268,4 +325,4 @@ def generate_sql():
# Return error message prepended, to be displayed in the textarea
return f"-- Error generating SQL: {error}\n\n"
return generated_sql if generated_sql else "-- No SQL generated."
return generated_sql if generated_sql else "-- No SQL generated."
+2 -2
View File
@@ -186,7 +186,7 @@ def add_tag_to_workout(workout_id):
# Render the partial with the complete list of tags (selected and unselected)
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
return render_template('partials/workout_tag_editor.html' if request.args.get('view') == 'session' else 'partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
@login_required
@@ -271,4 +271,4 @@ def create_new_tag_for_workout(workout_id):
# Render the partial with the complete list of tags
# Use 'tags' as the variable name consistent with the other endpoint and likely the partial
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
return render_template('partials/workout_tag_editor.html' if request.args.get('view') == 'session' else 'partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
+82 -35
View File
@@ -5,7 +5,9 @@ from flask_login import login_required, current_user
from extensions import db
from decorators import validate_workout, validate_topset, require_ownership, validate_person
from utils import convert_str_to_date
from collections import defaultdict # Import defaultdict
from collections import defaultdict # Import defaultdict
from datetime import date
from dateutil.relativedelta import relativedelta
workout_bp = Blueprint('workout', __name__)
htmx = HTMX()
@@ -197,6 +199,19 @@ def get_workout_distribution(person_id, workout_id):
distribution=distribution,
category_name=category)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/summary", methods=['GET'])
@login_required
@validate_workout
@require_ownership
def get_workout_summary(person_id, workout_id):
top_sets = db.execute(
"SELECT exercise_id, repetitions FROM topset WHERE workout_id = %s",
[workout_id],
)
return render_template('partials/workout_summary.html', person_id=person_id,
workout_id=workout_id, top_sets=top_sets)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/achievements", methods=['GET'])
@validate_topset
def get_topset_achievements(person_id, workout_id, topset_id):
@@ -207,7 +222,7 @@ def get_topset_achievements(person_id, workout_id, topset_id):
@validate_topset
def get_topset(person_id, workout_id, topset_id):
topset = db.get_topset(topset_id) # Keep using db.py for simple gets for now
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'))
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'), existing_row=True)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/edit_form", methods=['GET'])
@login_required
@@ -271,7 +286,7 @@ def update_topset(person_id, workout_id, topset_id):
db.update_topset(exercise_id, repetitions, weight, topset_id)
exercise = db.get_exercise(exercise_id)
db.activityRequest.log(current_user.id, 'UPDATE_SET', 'topset', topset_id, f"Updated set {topset_id}: {repetitions} x {weight}kg {exercise['name']}")
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight)
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight, existing_row=True), 200, {"HX-Trigger": "topsetChanged"}
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/delete", methods=['DELETE'])
@login_required
@@ -281,7 +296,7 @@ def delete_topset(person_id, workout_id, topset_id):
topset = db.get_topset(topset_id)
db.delete_topset(topset_id)
db.activityRequest.log(current_user.id, 'DELETE_SET', 'topset', topset_id, f"Deleted set {topset_id}: {topset['exercise_name']} in workout {workout_id}")
return ""
return f'<tr id="extra-target-{topset_id}" hx-swap-oob="delete"></tr>', 200, {"HX-Trigger": "topsetChanged"}
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])
def get_most_recent_topset_for_exercise(person_id, workout_id):
@@ -293,44 +308,76 @@ def get_most_recent_topset_for_exercise(person_id, workout_id):
if not topset:
exercise = db.execute("select name from exercise where exercise_id=%s", [exercise_id], one=True)
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, exercise_id=exercise_id, exercise_name=exercise['name'])
(repetitions, weight, exercise_name) = topset
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercise_id=exercise_id, exercise_name=exercise_name, repetitions=repetitions, weight=weight)
(repetitions, weight, exercise_name) = topset
stalled_sessions = 0
if repetitions is not None:
recent_sets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit=1)
if recent_sets:
achievements = db.get_topset_achievements(recent_sets[0]['topset_id'])
stalled_sessions = achievements.get('stalled_sessions', 0)
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercise_id=exercise_id, exercise_name=exercise_name, repetitions=repetitions, weight=weight, stalled_sessions=stalled_sessions)
@workout_bp.route("/person/<int:person_id>/exercise/<int:exercise_id>/history", methods=['GET'])
def get_exercise_history(person_id, exercise_id):
limit = request.args.get('limit', type=int, default=5)
offset = request.args.get('offset', type=int, default=0)
source_topset_id = request.args.get('source_topset_id', type=int)
topsets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit, offset)
title = "History"
best_fit_formula = None
latest_workout_id = None
latest_topset_id = None
if topsets:
# Fetch progress data to get title and best_fit_formula if there's history
exercise_progress = db.get_exercise_progress_for_user(
person_id, exercise_id, epoch='all')
if exercise_progress:
title = exercise_progress.get('title', "History")
best_fit_formula = exercise_progress.get('best_fit_formula')
latest_workout_id = exercise_progress.get('latest_workout_id')
latest_topset_id = exercise_progress.get('latest_topset_id')
def get_exercise_history(person_id, exercise_id):
limit = request.args.get('limit', type=int, default=5)
offset = request.args.get('offset', type=int, default=0)
source_topset_id = request.args.get('source_topset_id', type=int)
epoch = request.args.get('epoch', default='All')
min_date = convert_str_to_date(request.args.get('min_date'))
max_date = convert_str_to_date(request.args.get('max_date'))
if epoch in ('1M', '3M', '6M'):
min_date = date.today() - relativedelta(months=int(epoch[0]))
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, 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))
topsets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit, offset, min_date, max_date)
title = "History"
best_fit_formula = None
latest_workout_id = None
latest_topset_id = None
forecast = {'available': False}
if topsets:
# Fetch progress data to get title and best_fit_formula if there's history
exercise_progress = db.get_exercise_progress_for_user(
person_id,
exercise_id,
epoch=epoch,
min_date=min_date,
max_date=max_date,
forecast_enabled=forecast_enabled,
forecast_weeks=forecast_weeks,
forecast_target_reps=forecast_target_reps)
if exercise_progress:
title = exercise_progress.get('title', "History")
best_fit_formula = exercise_progress.get('best_fit_formula')
latest_workout_id = exercise_progress.get('latest_workout_id')
latest_topset_id = exercise_progress.get('latest_topset_id')
forecast = exercise_progress.get('forecast', forecast)
forecast_target_reps = exercise_progress.get('forecast_target_reps', forecast_target_reps)
return render_template('partials/exercise_history.html',
person_id=person_id,
exercise_id=exercise_id,
topsets=topsets,
limit=limit,
offset=offset,
source_topset_id=source_topset_id,
title=title,
best_fit_formula=best_fit_formula,
latest_workout_id=latest_workout_id,
latest_topset_id=latest_topset_id)
source_topset_id=source_topset_id,
title=title,
best_fit_formula=best_fit_formula,
latest_workout_id=latest_workout_id,
latest_topset_id=latest_topset_id,
forecast=forecast,
forecast_enabled=forecast_enabled,
forecast_weeks=forecast_weeks,
forecast_target_reps=forecast_target_reps,
selected_epoch=epoch, min_date=min_date, max_date=max_date)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>", methods=['GET'])
def show_workout(person_id, workout_id):
@@ -341,4 +388,4 @@ def show_workout(person_id, workout_id):
return redirect(url_for('calendar.get_calendar', person_id=person_id)) # Redirect to calendar on error
if htmx:
return render_block(current_app.jinja_env, 'workout.html', 'content', **view_model)
return render_template('workout.html', **view_model)
return render_template('workout.html', **view_model)
+86
View File
@@ -0,0 +1,86 @@
"""Read-only production audit; raw sets are kept in memory, never committed.
Run: .venv/Scripts/python scripts/audit_forecasts.py --person Peter
Requires the existing Dokku SSH connection. SELECT/COPY only, no DB changes.
"""
import argparse
from collections import defaultdict
import csv
from datetime import date
import io
from pathlib import Path
import subprocess
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from forecasting import (TransitionModel, backtest, benchmark, build_forecast,
e1rm, sessions, training_blocks, representative_set)
def load_person(name):
escaped = name.replace("'", "''")
query = f"""COPY (SELECT e.exercise_id, e.name, w.start_date, t.weight, t.repetitions
FROM topset t JOIN workout w USING(workout_id)
JOIN person p USING(person_id) JOIN exercise e USING(exercise_id)
WHERE lower(p.name) = lower('{escaped}')
ORDER BY e.exercise_id,w.start_date,t.topset_id) TO STDOUT WITH CSV HEADER;"""
output = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',
'dokku@peterstockings.com', 'postgres:connect', 'workout-db'],
input=query, text=True, capture_output=True, check=True)
lines = output.stdout.splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith('exercise_id,'))
groups = defaultdict(list)
for row in csv.DictReader(io.StringIO('\n'.join(lines[start:]))):
groups[(int(row['exercise_id']), row['name'])].append(
(date.fromisoformat(row['start_date']), float(row['weight']), int(row['repetitions'])))
return groups
def run(groups):
totals = {h: defaultdict(list) for h in (1, 4, 8)}
split_count, eligible, session_count = 0, 0, 0
for (exercise_id, name), data in groups.items():
rows = sessions(*zip(*data))
if not rows:
continue
blocks = training_blocks(rows)
split_count += len(blocks) - 1
session_count += len(rows)
eligible += len(blocks[-1]) >= 4
for block in blocks:
# Evaluate multiple horizons without letting a test cross a layoff.
for end in range(8, len(block)):
train = block[:end]
model = TransitionModel(train)
simulations = model.simulate(8)
for h in (1, 4, 8):
if end + h > len(block):
continue
actual = block[end + h - 1]
if h == 1:
predicted = model.next_set()[0][0]
else:
w, r = simulations[h]
predicted = representative_set(w,r)
candidates = {'transitions': predicted}
candidates.update({m: benchmark(train, m, h) for m in ('repeat', 'damped', 'linear')})
for method, (w, r) in candidates.items():
totals[h][method].append((abs(w - actual[1]), abs(r - actual[2]),
abs(float(e1rm(w,r)-e1rm(actual[1],actual[2])))))
f = build_forecast([], [r[0] for r in rows], 8,
weights=[r[1] for r in rows], repetitions=[r[2] for r in rows])
status = f['next_session']['set_display'] if f['available'] else f['reason']
print(f'{exercise_id:4} | {name:42.42} | {len(rows):3} sessions | {len(blocks):2} blocks | latest {len(blocks[-1]):3} | {status}')
print(f'\nTOTAL: {len(groups)} exercises; {session_count} unique valid sessions; {split_count} breaks; {eligible} current blocks with 4+ sessions')
for h, methods in totals.items():
print(f'\n{h}-SESSION HOLDOUTS (load kg / reps / E1RM kg mean absolute error):')
for method, errors in methods.items():
print(method, 'n=',len(errors), 'MAE=',np.mean(errors,axis=0).round(3).tolist())
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--person', default='Peter')
args = parser.parse_args()
run(load_person(args.person))
+72
View File
@@ -0,0 +1,72 @@
"""Local, read-only UI preview using Peter's production sets in memory."""
from pathlib import Path
import sys
from datetime import date
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from flask import Flask, Blueprint, request, render_template, render_template_string
from dateutil.relativedelta import relativedelta
from scripts.audit_forecasts import load_person
from forecasting import e1rm
from utils import get_exercise_graph_model
root = Path(__file__).resolve().parents[1]
app = Flask(__name__, template_folder=str(root / 'templates'), static_folder=str(root / 'static'))
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.jinja_env.filters['strftime'] = lambda d: d.strftime('%d %b %Y')
data = load_person('Peter')
workout = Blueprint('workout', __name__)
def model(exercise_id):
(key, all_rows) = next((k,r) for k,r in data.items() if k[0] == exercise_id)
epoch = request.args.get('epoch', 'All')
minimum, maximum = request.args.get('min_date'), request.args.get('max_date')
minimum = date.fromisoformat(minimum) if minimum else None
maximum = date.fromisoformat(maximum) if maximum else None
if epoch in ('1M','3M','6M'):
minimum = date.today() - relativedelta(months=int(epoch[0]))
rows = [r for r in all_rows if (not minimum or r[0] >= minimum) and (not maximum or r[0] <= maximum)]
training = [dict(start_date=d, weight=w, repetitions=r) for d,w,r in all_rows if d <= max(x[0] for x in rows)]
m = get_exercise_graph_model(key[1], [round(float(e1rm(r[1],r[2])),1) for r in rows],
[r[2] for r in rows], [r[1] for r in rows], [r[0] for r in rows], ['']*len(rows),
epoch, 1007, exercise_id, forecast_enabled=request.args.get('forecast','true')=='true',
forecast_weeks=request.args.get('forecast_weeks',8,type=int), forecast_training_rows=training)
m.update(latest_topset_id=1, latest_workout_id=1,
topsets=[dict(start_date=d,weight=w,repetitions=r,workout_id=1,topset_id=i+1) for i,(d,w,r) in enumerate(reversed(rows[-5:]))],
offset=0, limit=5, source_topset_id=1)
return m
@app.route('/person/<int:person_id>/exercise/<int:exercise_id>/sparkline')
def get_exercise_progress_for_user(person_id,exercise_id):
return render_template('partials/sparkline.html', **model(exercise_id))
@workout.route('/person/<int:person_id>/exercise/<int:exercise_id>/history')
def get_exercise_history(person_id,exercise_id):
return render_template('partials/exercise_history.html', **model(exercise_id))
@workout.route('/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/achievements')
def get_topset_achievements(**kwargs):
return ''
app.register_blueprint(workout)
@app.route('/')
def index():
exercise_id = request.args.get('exercise_id',1143,type=int)
graph = render_template('partials/sparkline.html', **model(exercise_id))
return render_template_string('''<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Workout forecast preview</title><link rel="stylesheet" href="/static/css/tailwind.css">
<link rel="stylesheet" href="/static/css/forecast.css"><script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/hyperscript.min.js" defer></script><script src="/static/js/forecast.js" defer></script></head>
<body style="background:#f5f7f8;font-family:system-ui;margin:0;padding:16px">
<main style="max-width:680px;margin:24px auto;background:white;border:1px solid #e2e8f0;border-radius:16px;padding:16px">
<p style="font-size:11px;color:#94a3b8;margin-bottom:16px">PETER / PROGRESS</p>{{ graph|safe }}</main></body></html>''', graph=graph)
if __name__ == '__main__':
app.run(port=5057, debug=False)
+12 -8
View File
@@ -2,19 +2,23 @@
Database migration runner
Execute SQL migration files using the Flask app's database connection
"""
import os
import sys
# Add parent directory to path to import app
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extensions import db as database
import os
import sys
# Add the project root to the import path when this file is invoked directly.
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
from dotenv import load_dotenv
load_dotenv(os.path.join(PROJECT_ROOT, '.env'))
from extensions import db as database
def run_migration(migration_file):
"""Execute a SQL migration file"""
try:
# Read migration file
migration_path = os.path.join('migrations', migration_file)
migration_path = os.path.join(PROJECT_ROOT, 'migrations', migration_file)
if not os.path.exists(migration_path):
print(f"ERROR: Migration file not found: {migration_path}")
sys.exit(1)
+39
View File
@@ -0,0 +1,39 @@
.exercise-progress{color:#334155;text-align:left;min-width:0;padding:12px 4px}
.progress-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px}
.progress-title{min-width:0}.progress-title h4{font-size:16px;font-weight:650;line-height:1.4;color:#1e293b;margin:0}
.progress-actions{display:flex;gap:5px;flex-shrink:0}.progress-action,.forecast-horizon{border:1px solid #dbe3e9;border-radius:7px;background:white;color:#475569;font-size:12px;padding:6px 9px;line-height:1.3;cursor:pointer}
.progress-action:hover,.forecast-horizon:hover{background:#f1f5f9}.progress-action.is-active{background:#ecfdf5;border-color:#a7d8c5;color:#047857}
.forecast-summary{border:1px solid #cfe5db;border-radius:12px;background:linear-gradient(125deg,#f0faf5,#fafdfb);padding:14px;margin:12px 0 16px}
.forecast-summary-grid{display:grid;grid-template-columns:1.4fr 1fr;gap:18px;align-items:center}
.forecast-eyebrow{font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700;color:#527466}
.forecast-primary{font-size:25px;line-height:1.25;font-weight:700;color:#123c30;margin:5px 0;font-variant-numeric:tabular-nums}
.forecast-secondary{font-size:21px;font-weight:650;color:#334155;margin:5px 0;white-space:nowrap}.forecast-secondary span{font-size:11px;font-weight:400}
.forecast-horizon-summary{border-left:1px solid #d4e8df;padding-left:18px}
.forecast-caption{font-size:11px;color:#64748b;line-height:1.6}.forecast-assumption{margin:10px 0 0}
.forecast-controls{display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:12px}.forecast-controls>span{margin-right:5px}
.progress-epochs{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin:8px 0}.progress-epochs>span{margin-right:8px}
.forecast-horizon{padding:5px 10px;font-variant-numeric:tabular-nums}.forecast-horizon.is-active{background:#16624c;color:white;border-color:#16624c}
.forecast-chart{display:block;width:100%;height:230px;outline-offset:4px;border-radius:3px;touch-action:pan-y}
.forecast-chart:focus-visible{outline:2px solid #0d9470}.forecast-hit{cursor:crosshair}
.forecast-plot{display:flex;gap:6px;margin-top:7px}.forecast-plot .forecast-chart{flex:1;min-width:0}.forecast-y-axis{width:31px;flex-shrink:0;display:flex;flex-direction:column;justify-content:space-between;font-size:10px;color:#94a3b8;text-align:right;padding:3px 0}
.forecast-axis{display:flex;justify-content:space-between;font-size:10px;color:#94a3b8;margin:7px 0 12px;font-variant-numeric:tabular-nums}
.forecast-readout{border:1px solid #e2e8f0;border-radius:10px;background:#fff;padding:10px 12px;min-height:94px}
.forecast-readout-heading{display:flex;justify-content:space-between;gap:12px;font-size:11px;color:#64748b;line-height:1.5;margin-bottom:8px}
.forecast-readout-heading time{color:#334155;font-weight:600;white-space:nowrap}
.forecast-readout-values{display:grid;grid-template-columns:1.2fr .7fr 1.2fr;gap:10px}.forecast-readout-values strong{display:block;color:#334155;font-size:12px;font-weight:600;line-height:1.5;font-variant-numeric:tabular-nums}
.forecast-chart-hint{font-size:10px;color:#94a3b8;margin:7px 0;text-align:center}
.forecast-table-wrap{overflow:auto;max-height:320px;border:1px solid #e2e8f0;border-radius:10px;margin:12px 0;background:white}
.forecast-table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap;font-variant-numeric:tabular-nums}
.forecast-table th{position:sticky;top:0;background:#f3f7f5;color:#64748b;font-size:10px;font-weight:600;padding:9px 12px;z-index:1}
.forecast-table td{padding:10px 12px;border-top:1px solid #f1f5f9;font-size:12px;color:#475569}.forecast-table td strong{color:#234d3b}.forecast-table tr:hover td{background:#f8fbf9}
.forecast-table-title{font-size:14px;font-weight:650;color:#234d3b;margin-top:16px}
.forecast-mobile-range{display:none}
@media(max-width:520px){.forecast-table .forecast-desktop-range{display:none}.forecast-mobile-range{display:block;font-size:9px;color:#94a3b8;margin-top:3px}.forecast-table-wrap .forecast-table td,.forecast-table-wrap .forecast-table th{padding:9px 6px}}
.forecast-method{margin:14px 0;border-top:1px solid #e2e8f0;padding-top:12px}.forecast-method summary{font-size:12px;color:#475569;cursor:pointer;font-weight:500}.forecast-method p{margin:8px 0;font-size:12px;line-height:1.7;color:#64748b}
.forecast-facts{display:flex;flex-wrap:wrap;gap:8px 18px;margin:10px 0;font-size:11px;color:#64748b}.forecast-facts strong{color:#334155}
@media(max-width:520px){.progress-heading{flex-wrap:wrap;gap:7px}.progress-title{flex:1 1 180px}.progress-actions{margin-left:auto}.forecast-summary{padding:12px}.forecast-summary-grid{gap:10px}.forecast-primary{font-size:21px}.forecast-horizon-summary{padding-left:10px}.forecast-secondary{font-size:18px}.forecast-secondary span{display:block}.forecast-chart{height:190px}.forecast-readout-values{grid-template-columns:1fr 1fr}.forecast-readout-values>div:last-child{grid-column:1/-1;display:flex;align-items:center;gap:8px}.forecast-readout{min-height:120px}.forecast-chart-hint{font-size:9px}.forecast-table td,.forecast-table th{padding:9px}.exercise-progress>.inline-flex{display:flex}.exercise-progress>.inline-flex>div{padding:7px 12px;font-size:12px}}
/* Keep the exercise's achievements and time controls visually centered. */
.progress-badges{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px;margin:6px 0 12px}
.progress-badges:empty{display:none}
.progress-epochs,.forecast-controls{justify-content:center}
+111
View File
@@ -0,0 +1,111 @@
/* Workout workspace: keep logging readable on wide screens. */
.workout-workspace{max-width:1360px;margin:0 auto;padding:8px 12px 40px;color:#253449}
.workout-workspace nav{margin-bottom:28px}
.workout-header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}
.workout-eyebrow{font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#64748b;margin:0 0 8px}
.workout-header h1{font-size:30px;line-height:1.25;font-weight:750;letter-spacing:-.035em;margin-bottom:8px}
.workout-header h1 span{color:#64748b;font-weight:400}
.workout-layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:24px;align-items:start}
.workout-main,.workout-sidebar{min-width:0;display:grid;gap:20px}
.workout-card{background:#fff;border:1px solid #e1e7ec;border-radius:14px;box-shadow:0 2px 4px #182c4203;min-width:0}
.workout-logger{padding:24px;border-top:3px solid #0891a0}
.workout-section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:22px}
.workout-section-heading h2{font-size:18px;line-height:1.4;font-weight:700;letter-spacing:-.02em}
.workout-subtle{font-size:12px;line-height:1.6;color:#64748b;font-weight:400}
.workout-section-heading p.workout-subtle{margin-top:5px}
.workout-set-fields{display:grid;grid-template-columns:minmax(140px,1fr) 84px 104px auto;align-items:end;gap:12px}
.workout-set-field{min-width:0}
.workout-set-field label{font-size:11px;letter-spacing:.03em;text-transform:none;color:#64748b;margin-bottom:7px}
.workout-set-field input{height:44px;border-radius:8px;padding:10px 12px;font-size:14px;background:#f8fafc;border-color:#dbe3e9;color:#253449}
.workout-set-field input:focus{outline:2px solid #0891a0;outline-offset:2px}
.workout-add-button{display:flex;align-items:center;justify-content:center;gap:5px;min-height:44px;padding:10px 15px;background:#087f8c;color:white;border-radius:8px;font-size:13px;font-weight:650;white-space:nowrap}
.workout-add-button:hover{background:#066975}
.workout-workspace button:focus-visible,.workout-workspace summary:focus-visible{outline:2px solid #0891a0;outline-offset:3px}
.workout-button{display:flex;align-items:center;gap:16px;padding:10px 14px;border:1px solid #dbe3e9;background:#fff;border-radius:8px;font-size:12px;cursor:pointer;list-style:none}
.workout-delete{color:#8c5960;flex-shrink:0}.workout-delete:hover{color:#b42335}
.workout-delete:hover{background:#fff1f2}
.workout-results>.workout-section-heading{padding:22px 24px 18px;margin:0}
.workout-table{width:100%;border-collapse:collapse;table-layout:fixed;text-align:left}
.workout-table>thead th{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:#64748b;background:#f8fafc;padding:12px 20px;border-block:1px solid #e8edf1;font-weight:650}
.workout-table>thead th:first-child{width:53%}
.workout-table>thead th:last-child{width:82px;padding-inline:0}
.workout-table .workout-set-row>td{padding:18px 20px;vertical-align:middle;border-bottom:1px solid #edf0f3;font-size:13px}
.workout-table .workout-set-row>td:last-child{display:table-cell;padding:10px 6px}
.workout-table .workout-set-row>td:last-child>div{flex-direction:row}
.workout-table .workout-set-row>td:last-child button{color:#718096;border-radius:7px}
.workout-table .workout-set-row>td:last-child button:hover{color:#087f8c;background:#edf7f8}
.workout-exercise-name{display:flex;align-items:flex-start;flex-direction:column;gap:8px;overflow-wrap:anywhere}
.workout-exercise-tools{display:flex;gap:8px;flex-wrap:wrap}
.workout-exercise-tools button{gap:4px;padding:3px 0;font-size:11px;font-weight:500;color:#64748b;min-height:26px}
.workout-exercise-tools button:hover{color:#087f8c;background:transparent}
.workout-exercise-tools svg{width:13px;height:13px}
.workout-empty{display:none;padding:42px 24px;text-align:center;color:#64748b;font-size:13px;line-height:1.7}
.workout-empty strong{display:block;color:#334155;font-size:16px;margin-bottom:6px}
.workout-results:not(:has(.workout-set-row)) .workout-empty{display:block}
.workout-results:not(:has(.workout-set-row)) .workout-table{display:none}
.workout-summary{padding:22px}
.workout-summary dl{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:18px 0 14px}
.workout-summary dl>div{display:flex;flex-direction:column-reverse;gap:4px}
.workout-summary dt{font-size:11px;color:#64748b}
.workout-summary dd{font-size:27px;line-height:1.2;letter-spacing:-.04em;font-weight:700;font-variant-numeric:tabular-nums}
.workout-disclosure>summary{position:relative;padding:20px 40px 20px 20px;font-weight:650;font-size:14px;cursor:pointer;list-style:none}
.workout-disclosure>summary:after{content:'+';position:absolute;right:20px;top:20px;color:#64748b;font-size:18px;font-weight:400}
.workout-disclosure[open]>summary:after{content:'−'}
.workout-disclosure>summary .workout-subtle{display:block;margin-top:3px}
.workout-disclosure-body{padding:0 20px 20px}
.workout-breakdown h3{font-size:11px;font-weight:650;color:#64748b;margin:0 0 12px}
.workout-distribution-bar{display:flex;gap:2px;height:7px;border-radius:4px;overflow:hidden;margin-bottom:14px}
.workout-distribution-legend{display:grid;gap:11px;font-size:12px}
.workout-distribution-legend li{display:flex;justify-content:space-between;gap:10px;font-variant-numeric:tabular-nums}
.workout-distribution-legend li>span:last-child{color:#64748b}
.workout-legend-label{display:flex;align-items:center;gap:8px}
.workout-legend-label i{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.workout-secondary-details{margin-top:20px;padding-top:14px;border-top:1px solid #e8edf1}
.workout-secondary-details>summary,.workout-previous-details>summary{font-size:12px;color:#64748b;cursor:pointer}
.workout-secondary-details .workout-breakdown{margin-top:20px}
.workout-previous-details{border-top:1px solid #e8edf1;margin-top:18px;padding-top:14px}
.workout-previous-details>summary{display:flex;align-items:center;gap:10px;flex-wrap:wrap;list-style:none}
.workout-previous-details>summary::-webkit-details-marker{display:none}
.workout-previous-label{margin-right:auto}
.workout-previous-label:before{content:'›';display:inline-block;margin-right:8px}
.workout-previous-details[open] .workout-previous-label:before{transform:rotate(90deg)}
.workout-previous-value{color:#334155;font-variant-numeric:tabular-nums;white-space:nowrap}
.workout-stall-badge{padding:4px 8px;border-radius:5px;background:#fff4df;color:#8c5a10;font-size:11px;font-weight:500;white-space:nowrap}
@media(max-width:600px){.workout-previous-label{flex-basis:100%}}
.workout-table .extra-content-container:not(:empty){background:#f8fafc;padding:18px 20px;border-bottom:1px solid #e1e7ec}
.workout-table .extra-content-container .progress-heading{padding-right:22px}
.workout-workspace .forecast-chart{height:220px}
.workout-workspace .forecast-chart path{vector-effect:non-scaling-stroke;stroke-width:1.8px}
.workout-workspace .forecast-chart circle{opacity:.6}
.workout-workspace .exercise-progress{padding:8px 0;max-width:100%}
.workout-workspace .progress-title h4{font-size:14px}
.workout-workspace .exercise-progress>h2{color:#64748b}
@media(min-width:1600px){.workout-workspace{padding-top:16px}.workout-layout{gap:28px;grid-template-columns:minmax(0,1fr) 320px}}
@media(max-width:1100px){.workout-layout{grid-template-columns:minmax(0,1fr) 260px;gap:16px}.workout-logger{padding:20px}.workout-set-fields{grid-template-columns:minmax(0,1fr) 76px 90px;gap:10px}.workout-set-submit{grid-column:1/-1}.workout-add-button{width:100%}}
@media(max-width:900px){.workout-layout{grid-template-columns:minmax(0,1fr)}.workout-sidebar{grid-template-columns:repeat(2,minmax(0,1fr));align-items:start}.workout-set-fields{grid-template-columns:minmax(140px,1fr) 84px 104px auto}.workout-set-submit{grid-column:auto}}
@media(max-width:600px){.workout-workspace{padding:4px 0 24px}.workout-workspace nav{margin-bottom:20px}.workout-header{gap:8px;align-items:flex-start;margin-bottom:18px}.workout-header h1{font-size:24px}.workout-header h1 span{display:block;font-size:16px;letter-spacing:0;margin-top:4px}.workout-button{gap:8px;padding:8px 10px;font-size:11px}.workout-main,.workout-sidebar{gap:14px}.workout-sidebar{grid-template-columns:minmax(0,1fr)}.workout-logger{padding:18px 16px}.workout-section-heading{margin-bottom:18px}.workout-section-heading>.workout-subtle{display:none}.workout-set-fields{grid-template-columns:1fr 1fr;gap:14px 10px}.workout-set-field:first-child,.workout-set-submit{grid-column:1/-1}.workout-results>.workout-section-heading{padding:18px 14px}.workout-table>thead th{padding:10px 12px}.workout-table>thead th:first-child{width:49%}.workout-table>thead th:last-child{width:44px}.workout-table .workout-set-row>td{padding:16px 12px;font-size:12px}.workout-table .workout-set-row>td:last-child{padding:6px 0}.workout-table .workout-set-row>td:last-child>div{flex-direction:column}.workout-exercise-tools{gap:8px}.workout-exercise-tools button{font-size:10px;min-height:30px}.workout-exercise-tools svg{display:none}.workout-table .extra-content-container:not(:empty){padding:14px 10px}.workout-workspace .forecast-chart{height:190px}.workout-summary dl{margin:14px 0}.workout-summary{padding:18px 20px}}
/* Direct session editing: the card itself is the control. */
.workout-note-trigger{display:block;width:100%;padding:20px;text-align:left;border-radius:14px}
.workout-note-trigger:hover{background:#f8fafc}
.workout-inline-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:14px;font-weight:650;margin-bottom:7px}
.workout-inline-action{font-size:11px;font-weight:500;color:#087f8c}
.workout-note-preview{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.7;color:#475569;max-height:160px;overflow:auto}
.workout-note-editor{padding:20px}
.workout-note-editor>label,.workout-tags-card>h2{display:block;font-size:14px;font-weight:650;margin-bottom:12px}
.workout-note-editor textarea,.workout-tag-create input{width:100%;min-width:0;background:#f8fafc;border:1px solid #dbe3e9;border-radius:8px;padding:10px 12px;font-size:13px;color:#253449}
.workout-note-editor textarea{display:block;resize:vertical;line-height:1.6;min-height:132px}
.workout-note-editor textarea:focus,.workout-tag-create input:focus{outline:2px solid #0891a0;outline-offset:2px}
.workout-editor-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}
.workout-editor-actions button{min-height:38px;font-size:12px;padding:8px 12px}
.workout-editor-error{font-size:12px;line-height:1.5;color:#b42335;margin-top:10px}
.workout-tags-card{padding:20px}
.workout-tag-choices{display:flex;flex-wrap:wrap;gap:8px;max-height:180px;overflow:auto;padding:4px;margin:8px -4px 12px}
.workout-tag-choice{display:flex;align-items:center;gap:6px;padding:7px 9px;border:1px solid #dbe3e9;border-radius:8px;font-size:12px;color:#64748b;cursor:pointer;overflow-wrap:anywhere;max-width:100%}
.workout-tag-choice:has(input:checked){background:#edf7f8;border-color:#9acbd0;color:#086e79}
.workout-tag-choice:hover{border-color:#0891a0}
.workout-tag-choice input{width:13px;height:13px;flex-shrink:0;accent-color:#087f8c}
.workout-tag-choice:has(input:focus-visible){outline:2px solid #0891a0;outline-offset:2px}
.workout-tag-create{display:flex;gap:8px}
.workout-tag-create button{flex-shrink:0;padding:8px 10px;color:#087f8c}
.workout-tag-editor input:disabled,.workout-tag-editor button:disabled,.workout-note-editor button:disabled{opacity:.55;cursor:wait}
+39
View File
@@ -0,0 +1,39 @@
/* Delegation survives HTMX swaps; all readout values are plain text. */
(() => {
if (window.workoutForecastEvents) return;
window.workoutForecastEvents = true;
function inspect(hit) {
const panel = hit.closest('.exercise-progress');
if (!panel) return;
for (const key of ['kind', 'date', 'e1rm', 'topset', 'range']) {
const field = panel.querySelector(`[data-readout="${key}"]`);
if (field) field.textContent = hit.dataset[key];
}
const cursor = panel.querySelector('.forecast-cursor');
if (cursor) {
cursor.setAttribute('x1', hit.dataset.position);
cursor.setAttribute('x2', hit.dataset.position);
}
panel.querySelectorAll('.forecast-hit').forEach(p => p.removeAttribute('data-selected'));
hit.dataset.selected = 'true';
}
for (const event of ['pointerover', 'click']) {
document.addEventListener(event, e => {
const hit = e.target.closest?.('.forecast-hit');
if (hit) inspect(hit);
});
}
document.addEventListener('keydown', e => {
if (!e.target.matches?.('.forecast-chart') || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
const hits = [...e.target.querySelectorAll('.forecast-hit')];
if (!hits.length) return;
let index = hits.findIndex(p => p.dataset.selected);
if (index < 0) {
index = hits.findIndex(p => p.dataset.kind === 'Current reading');
if (index < 0) index = hits.length - 1;
}
index = e.key === 'Home' ? 0 : e.key === 'End' ? hits.length - 1 : Math.max(0, Math.min(hits.length - 1, index + (e.key === 'ArrowRight' ? 1 : -1)));
e.preventDefault();
inspect(hits[index]);
});
})();
+5 -2
View File
@@ -15,8 +15,11 @@
<link href="/static/css/tailwind.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet">
<link href="/static/css/workout.css?v=2" rel="stylesheet">
<script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/hyperscript.min.js" defer></script>
<script src="/static/js/hyperscript.min.js" defer></script>
<link rel="stylesheet" href="/static/css/forecast.css?v=3">
<script src="/static/js/forecast.js?v=2" defer></script>
</head>
<body>
@@ -309,4 +312,4 @@
</body>
</html>
</html>
+19 -2
View File
@@ -94,7 +94,24 @@
hx-target="this" hx-swap="outerHTML">
</div>
{% if dashboard|length == 0 %}
{% if pinned_widgets %}
<section class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-2xl font-bold text-gray-900">Pinned insights</h2>
<p class="text-sm text-gray-500">Live tables and graphs from your saved SQL queries.</p>
</div>
<a hx-get="{{ url_for('sql_explorer.sql_explorer') }}" hx-push-url="true" hx-target="#container" class="text-sm font-medium text-cyan-600 hover:text-cyan-800 cursor-pointer">Manage insights</a>
</div>
<div class="w-full grid grid-cols-1 xl:grid-cols-2 gap-4">
{% for widget in pinned_widgets %}
{{ render_partial('partials/dashboard_insight.html', widget=widget) }}
{% endfor %}
</div>
</section>
{% endif %}
{% if dashboard|length == 0 %}
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert" id="no-workouts">
No workouts selected.
</div>
@@ -193,4 +210,4 @@
hx-target="#stats" hx-swap="innerHTML">
</div>
{% endblock %}
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
<article id="dashboard-widget-{{ widget.widget_id }}" class="bg-white shadow rounded-lg p-4 sm:p-6 border border-gray-100">
<div class="flex items-start justify-between gap-3 mb-4">
<div>
<h3 class="text-lg font-bold text-gray-900">{{ widget.title }}</h3>
<p class="text-xs text-gray-500 mt-1">Live SQL insight · {{ widget.row_count }} rows</p>
</div>
<button hx-delete="{{ url_for('sql_explorer.dashboard_widget', query_id=widget.query_id) }}" hx-vals='{"visualization_type":"{{ widget.visualization_type }}"}' hx-target="#dashboard-widget-{{ widget.widget_id }}" hx-swap="outerHTML" hx-confirm="Unpin '{{ widget.title }}' from Home?" class="text-gray-400 hover:text-red-600 p-1 rounded-lg hover:bg-red-50" title="Unpin insight">
<span class="sr-only">Unpin insight</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
</button>
</div>
{% if widget.error %}
<div class="p-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">This insight could not be refreshed: {{ widget.error }}</div>
{% elif widget.visualization_type == 'graph' and widget.plot_data %}
{{ render_partial('partials/sql_explorer/svg_plot.html', show_title=False, **widget.plot_data) }}
{% elif widget.results %}
<div class="overflow-x-auto max-h-96">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50"><tr>{% for col in widget.columns %}<th class="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase">{{ col }}</th>{% endfor %}</tr></thead>
<tbody class="divide-y divide-gray-100">
{% for row in widget.results %}<tr>{% for col in widget.columns %}<td class="px-3 py-2 whitespace-nowrap text-gray-700">{{ row[col] if row[col] is not none else 'NULL' }}</td>{% endfor %}</tr>{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-500 py-4">No data returned by this query.</p>
{% endif %}
</article>
+69 -29
View File
@@ -1,36 +1,76 @@
{% if offset == 0 %}
<div id="exercise-history-container"
class="w-full bg-gray-50 p-4 border-t border-gray-200 shadow-inner overflow-x-auto">
<div class="flex items-center justify-between sm:justify-center relative mb-1">
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2">
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4>
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center">
</div>
</div>
<div class="absolute left-0 z-10">
<button
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100"
title="Show Progress Graph"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-target="#exercise-history-container" hx-swap="outerHTML">
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
<span class="sr-only">Show Progress Graph</span>
</button>
</div>
</div>
{% if best_fit_formula %}
<div class="progress-heading">
<div class="progress-title">
<h4>{{ title }}</h4>
</div>
<button class="progress-action" title="Show Progress Graph"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps, min_date=min_date, max_date=max_date) }}"
hx-target="#exercise-history-container" hx-swap="outerHTML">Graph</button>
</div>
<div class="progress-badges" aria-label="Progress achievements" hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML"></div>
{% 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>
{% else %}
<div class="mb-4"></div>
{% endif %}
<table class="w-full text-left text-sm text-gray-500">
{% else %}
<div class="mb-4"></div>
{% endif %}
{% if forecast_enabled and forecast.available %}
<section class="exercise-progress" aria-label="Workout forecasts">
<div class="forecast-table-title">Upcoming sessions</div>
<p class="forecast-caption">Approximate dates, every {{ forecast.profile.cadence_days }} days. These are scenarios based on your recent routine.</p>
{% if forecast.stale %}<p class="forecast-caption">Historical projection from {{ forecast.last_date.strftime('%d %b %Y') }}. Log recent workouts to update.</p>{% endif %}
<div class="forecast-table-wrap">
<table class="forecast-table">
<thead><tr><th scope="col">Around</th><th scope="col">Top-set scenario</th><th scope="col">Est. 1RM</th><th class="forecast-desktop-range" scope="col">E1RM range</th></tr></thead>
<tbody>
{% for point in forecast.session_points %}
<tr><td>{{ point.date.strftime('%d %b %Y') }}</td>
<td><strong>{{ point.set_display }}</strong></td>
<td>{{ point.value }} kg<span class="forecast-mobile-range">{{ point.lower }}–{{ point.upper }} range</span></td>
<td class="forecast-desktop-range">{{ point.lower }}–{{ point.upper }} kg</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not forecast.session_points %}<p class="forecast-caption">No session expected inside this horizon at your recent cadence.</p>{% endif %}
<details class="forecast-method">
<summary>Why this forecast?</summary>
<div class="forecast-facts">
<span><strong>{{ forecast.observed_points }}</strong> sessions since {{ forecast.training_start.strftime('%d %b %Y') }}</span>
<span><strong>{{ forecast.training_blocks - 1 }}</strong> earlier training blocks excluded</span>
</div>
<p>At the current load: {{ forecast.profile.block_sessions }} sessions, from {{ forecast.profile.block_start_reps }} to a best of {{ forecast.profile.block_best_reps }} reps. {{ forecast.profile.block_stalls }} sessions did not improve that rep best.</p>
{% if forecast.profile.increment and forecast.profile.ceiling_evidence >= 2 %}
<p>Your recent load increases usually follow {{ forecast.profile.ceiling }} reps. The most common step is {{ '%g'|format(forecast.profile.increment) }} kg; forecasts use the recorded mix of steps. The model learns repeats, rep gains, difficult sessions and rep resets from this exercise.</p>
{% else %}<p>There is not enough repeated loading history to infer a reliable rep threshold. Repeated sets remain a possible outcome.</p>{% endif %}
{% if forecast.validation %}
{% set validation = forecast.validation.transitions %}
<p>Checked against {{ validation.count }} past next-session outcomes, each predicted using only earlier data: average error {{ validation.weight_mae }} kg load, {{ validation.reps_mae }} reps, {{ validation.e1rm_mae }} kg E1RM. Repeating the last set scored {{ forecast.validation.repeat.e1rm_mae }} kg E1RM error.</p>
{% else %}<p>Too few sessions for a useful accuracy check yet.</p>{% endif %}
<p>Ranges combine simulated outcomes with past forecast errors. They are not guaranteed limits. Each row is a representative outcome at that date; the rows are not a prescribed training plan. Longer horizons are less certain.</p>
</details>
<details class="forecast-method">
<summary>Equivalent loads at other rep counts · {{ forecast.end_date.strftime('%d %b %Y') }}</summary>
<p>These are formula equivalents of {{ forecast.projected_value }} kg E1RM, not predictions that you will choose these sets. Rep range: {{ forecast.observed_rep_min }}–{{ forecast.observed_rep_max }} in view, expanded by ±{{ forecast.rep_buffer }}.</p>
<div class="forecast-table-wrap">
<table class="forecast-table">
<thead><tr><th scope="col">Reps</th><th scope="col">Equivalent load</th><th scope="col">Load range</th></tr></thead>
<tbody>{% for top_set in forecast.top_sets %}
<tr><td>{{ top_set.repetitions }}</td><td>{{ top_set.weight_display }} kg</td><td>{{ top_set.lower_weight_display }}–{{ top_set.upper_weight_display }} kg</td></tr>
{% endfor %}</tbody>
</table>
</div>
<p>Rounded to {{ forecast.target_weight_increment_display }} kg display increments. Equivalents at rep counts far from your usual range are less reliable.</p>
</details>
</section>
{% elif forecast_enabled %}
<p class="forecast-caption">{{ forecast.reason }}</p>
{% endif %}
<table class="w-full text-left text-sm text-gray-500">
<thead class="text-xs text-gray-700 uppercase bg-gray-100">
<tr>
<th scope="col" class="px-3 py-2">Date</th>
@@ -63,7 +103,7 @@
<td colspan="2" class="px-3 py-3 text-center">
<button
class="text-sm text-blue-600 hover:underline font-medium px-4 py-2 border border-blue-600 rounded"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, limit=limit, offset=offset + limit, source_topset_id=source_topset_id) }}"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, limit=limit, offset=offset + limit, source_topset_id=source_topset_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps, epoch=selected_epoch, min_date=min_date, max_date=max_date) }}"
hx-target="#history-load-more-{{ source_topset_id }}-{{ offset + limit }}" hx-swap="outerHTML">
Load More
</button>
@@ -81,4 +121,4 @@
</tbody>
</table>
</div>
{% endif %}
{% endif %}
+23 -15
View File
@@ -2,7 +2,7 @@
<form class="w-full" id="new-set-workout-{{ workout_id }}"
hx-post="{{ url_for('workout.create_topset', person_id=person_id, workout_id=workout_id) }}" hx-swap="beforeend"
hx-target="#new-workout" _="on htmx:afterOnLoad
if #no-workouts add .hidden to #no-workouts end
if detail.xhr.status == 200
set #validation-error-{{ workout_id }}.innerText to ''
add .hidden to #validation-error-{{ workout_id }}
@@ -19,16 +19,16 @@
<div id="validation-error-{{ workout_id }}"
class="hidden text-red-500 text-xs italic mb-4 p-2 bg-red-50 border border-red-200 rounded"></div>
<div class="flex flex-wrap -mx-3 mb-2">
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-state">
<div class="workout-set-fields">
<div class="workout-set-field">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="exercise-search">
Exercise
</label>
{{ render_partial('partials/exercise/exercise_select.html', person_id=person_id,
exercise_id=exercise_id, exercise_name=exercise_name) }}
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<div class="workout-set-field">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
Reps
</label>
@@ -38,9 +38,9 @@
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<div class="workout-set-field">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Weight
Weight (kg)
</label>
<input
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
@@ -48,14 +48,15 @@
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[10%] px-2 md:px-3 mb-6 md:mb-0">
<div class="workout-set-submit">
<button
class="flex items-center justify-center py-2 px-2 md:px-3 mb-3 text-sm font-medium text-center text-gray-900 bg-cyan-600 hover:bg-cyan-700 rounded-lg border border-gray-300 hover:scale-[1.02] transition-transform mb-6 md:mb-0 mt-0 md:mt-6 w-full"
class="workout-add-button"
type="submit">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-7 h-7">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path
d="M12 4a1 1 0 011 1v6h6a1 1 0 110 2h-6v6a1 1 0 11-2 0v-6H5a1 1 0 110-2h6V5a1 1 0 011-1z" />
</svg>
<span>Add set</span>
</button>
</div>
@@ -66,17 +67,24 @@
<div hx-trigger="exerciseSelected from:body"
hx-get="{{ url_for('workout.get_most_recent_topset_for_exercise', person_id=person_id, workout_id=workout_id) }}"
hx-target="#new-set-form-container-{{ workout_id }}" hx-include="[name='exercise_id']">
hx-target="#new-set-form-container-{{ workout_id }}" hx-swap="outerHTML" hx-include="[name='exercise_id']">
</div>
{% if exercise_id %}
<div class="flex items-center justify-center">
<details class="workout-previous-details">
<summary>
<span class="workout-previous-label">Previous performance</span>
{% if repetitions %}<span class="workout-previous-value">{{ repetitions }} &times; {{ weight }} kg</span>{% endif %}
{% if stalled_sessions|default(0) > 0 %}
<span class="workout-stall-badge" title="Consecutive repeats of the same weight and reps">Stalled {{ stalled_sessions }} {{ 'time' if stalled_sessions == 1 else 'times' }}</span>
{% endif %}
</summary>
<div class="w-full">
<div class="hidden"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-trigger="load" hx-target="this" hx-swap="outerHTML">
hx-trigger="toggle once from:closest details" hx-target="this" hx-swap="outerHTML">
</div>
</div>
</div>
</details>
{% endif %}
</div>
</div>
+28
View File
@@ -0,0 +1,28 @@
<section id="workout-note-{{ workout_id }}" class="workout-card workout-note-card"
hx-target="this" hx-swap="outerHTML">
{% if is_edit|default(false) %}
<form class="workout-note-editor"
hx-put="{{ url_for('notes.update_workout_note', person_id=person_id, workout_id=workout_id, view='session') }}"
hx-disabled-elt="find button"
_="on htmx:responseError remove .hidden from #note-error-{{ workout_id }}">
<label for="session-note-input-{{ workout_id }}">Session notes</label>
<textarea id="session-note-input-{{ workout_id }}" name="note" rows="5"
placeholder="Cues, energy, how it felt…"
_="init call me.focus() end">{{ note or '' }}</textarea>
<p id="note-error-{{ workout_id }}" class="workout-editor-error hidden" role="alert">Could not save your note. Please try again.</p>
<div class="workout-editor-actions">
<button type="button" class="workout-button"
hx-get="{{ url_for('notes.get_workout_note', person_id=person_id, workout_id=workout_id, view='session') }}">Cancel</button>
<button type="submit" class="workout-add-button">Save note</button>
</div>
</form>
{% else %}
<button type="button" class="workout-note-trigger"
hx-get="{{ url_for('notes.get_workout_note_edit_form', person_id=person_id, workout_id=workout_id, view='session') }}"
aria-label="{{ 'Edit session notes' if note else 'Add session notes' }}">
<span class="workout-inline-heading">Session notes <span class="workout-inline-action">{{ 'Edit' if note else '+ Add' }}</span></span>
{% if note %}<span class="workout-note-preview">{% for line in note.split('\n') %}{{ line }}{% if not loop.last %}<br>{% endif %}{% endfor %}</span>
{% else %}<span class="workout-subtle">Cues, energy, how it felt</span>{% endif %}
</button>
{% endif %}
</section>
+152 -84
View File
@@ -9,13 +9,20 @@
{% endfor %}
{% endmacro %}
{% macro path_best_fit(best_fit_points, vb_height) %}
{% macro path_best_fit(best_fit_points, vb_height) %}
{% for value, position in best_fit_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
{% endfor %}
{% endmacro %}
{% endmacro %}
{% 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
{% endif %}
{% endmacro %}
{% macro circles(data_points, color) %}
{% for value, position in data_points %}
@@ -23,7 +30,23 @@
{% set y = (vb_height - value)+margin %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1"></circle>
{% endfor %}
{% endmacro %}
{% endmacro %}
{% 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 }}"
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 }}"></circle>
{% endif %}
{% endfor %}
{% endmacro %}
{% macro plot_line(points, color) %}
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
@@ -37,63 +60,79 @@
{% set parts = [random_int()] %}
{% set unique_id = parts|join('-') %}
<div class="relative" id="svg-plot-{{ unique_id }}" _="
on mouseover from .pnt-{{ unique_id }}
put event.target @data-msg into #popover-{{ unique_id }}
then remove .hidden from #popover-{{ unique_id }}
on mouseout from .pnt-{{ unique_id }}
add .hidden to #popover-{{ unique_id }}">
<div id="popover-{{ unique_id }}" class="absolute t-0 r-0 hidden bg-white border border-gray-300 p-2 z-10">
<!-- Popover content will be dynamically inserted here -->
</div>
<div class="flex items-center justify-between sm:justify-center relative mb-1">
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2">
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4>
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center">
</div>
</div>
<div class="absolute left-0 z-10">
<button
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
title="Show History"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=latest_topset_id) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
<span class="sr-only">Show History</span>
</button>
</div>
</div>
<div class="relative exercise-progress" id="svg-plot-{{ unique_id }}">
<div class="progress-heading">
<div class="progress-title">
<h4>{{ title }}</h4>
</div>
<div class="progress-actions">
<button class="progress-action" title="History and forecast tables"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=latest_topset_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps, epoch=selected_epoch, min_date=min_date, max_date=max_date) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">Table</button>
<button class="progress-action {% if forecast_enabled %}is-active{% endif %}"
aria-pressed="{{ 'true' if forecast_enabled else 'false' }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='false' if forecast_enabled else 'true', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
{% if selected_epoch == 'Custom' %}hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"{% endif %}
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">Forecast {{ 'on' if forecast_enabled else 'off' }}</button>
</div>
</div>
<div class="progress-badges" aria-label="Progress achievements" hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML"></div>
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
{% 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 %}
</h2>
<div class="inline-flex rounded-md shadow-sm w-full items-center justify-center mb-1">
{% for epoch in epochs %}
<div {% if selected_epoch==epoch %}
class="px-4 py-2 text-sm font-medium text-blue-700 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"
{% else %}
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white cursor-pointer"
hx-get='{{ url_for("get_exercise_progress_for_user", person_id=person_id, exercise_id=exercise_id, epoch=epoch) }}'
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="click" {% endif %}>
{% if epoch == 'Custom' %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="w-5 h-5 {% if selected_epoch == 'Custom' %}text-blue-700{% else %}text-gray-500{% endif %} group-hover:text-gray-900">
<path
d="M10 3.75a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM17.25 4.5a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM5 3.75a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 .75.75ZM4.25 17a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM17.25 17a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM9 10a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1 0-1.5h5.5A.75.75 0 0 1 9 10ZM17.25 10.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM14 10a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM10 16.25a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z" />
</svg>
{% else %}
{{ epoch}}
{% endif %}
</div>
{% endfor %}
</div>
{% if selected_epoch == 'Custom' %}
<div class="progress-epochs" aria-label="Visible history">
<span class="forecast-caption">History</span>
{% for epoch in epochs %}
<button class="forecast-horizon {% if selected_epoch == epoch %}is-active{% endif %}"
aria-pressed="{{ 'true' if selected_epoch == epoch else 'false' }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=epoch, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ epoch }}</button>
{% endfor %}
</div>
{% if forecast_enabled %}
<section class="forecast-summary" aria-label="Forecast summary">
{% if forecast.available %}
{% set next = forecast.next_session %}
<div class="forecast-summary-grid">
<div>
<div class="forecast-eyebrow">{{ 'Historical forecast' if forecast.stale else 'Likely next session' }}</div>
<div class="forecast-primary">{{ next.set_display }}</div>
<div class="forecast-caption">Around {{ next.date.strftime('%d %b %Y') }} · {{ next.value }} kg est. 1RM</div>
</div>
<div class="forecast-horizon-summary">
<div class="forecast-eyebrow">{{ forecast.horizon_weeks }}-week scenario</div>
<div class="forecast-secondary">{{ forecast.projected_value }} <span>kg est. 1RM</span></div>
<div class="forecast-caption">By {{ forecast.end_date.strftime('%d %b %Y') }}</div>
</div>
</div>
<div class="forecast-caption forecast-assumption">
{% if forecast.profile.increment and forecast.profile.ceiling_evidence >= 2 %}
Your pattern: build toward {{ forecast.profile.ceiling }} reps; most common increase +{{ '%g'|format(forecast.profile.increment) }} kg.
{% else %}
Learning from your recent repeats, rep changes and load changes.
{% endif %}
{% if forecast.stale %}Based on an older training block; log new sessions to update.{% endif %}
</div>
<div class="forecast-controls">
<span class="forecast-caption">Look ahead</span>
{% for weeks, label in forecast_horizons %}
<button class="forecast-horizon {% if forecast_weeks == weeks %}is-active{% endif %}"
aria-pressed="{{ 'true' if forecast_weeks == weeks else 'false' }}"
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, min_date=min_date, max_date=max_date) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ label }}</button>
{% endfor %}
</div>
{% if forecast.long_range %}<p class="forecast-caption forecast-assumption">Long-range scenario, assuming a similar routine. Exact sets and dates become less certain.</p>{% endif %}
{% else %}
<div class="forecast-eyebrow">Learning your pattern</div>
<p class="forecast-caption">{{ forecast.reason }}</p>
{% endif %}
</section>
{% endif %}
{% if selected_epoch == 'Custom' %}
<div class="flex flex-col md:flex-row justify-center pb-4">
<!-- Min Date -->
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
@@ -114,7 +153,7 @@
<input type="date" class="bg-gray-50 border border-gray-300 text-gray-900
text-sm rounded-lg focus:ring-blue-500
focus:border-blue-500 block w-full pl-10 p-2.5" name="min_date" value="{{ min_date }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-include="#svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
@@ -142,7 +181,7 @@
text-gray-900 text-sm rounded-lg
focus:ring-blue-500 focus:border-blue-500
block w-full pl-10 p-2.5" name="max_date" value="{{ max_date }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
@@ -167,7 +206,7 @@
<input type="number"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 w-full"
name="degree" value="{{ degree }}" min="1" step="1"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
@@ -176,35 +215,64 @@
</div>
{% endif %}
<svg viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none">
{% for plot in plots %}
<g class="{{ plot.label }}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
{% if forecast.available %}<div class="forecast-caption">Estimated 1RM · kg</div>{% endif %}
<div class="forecast-plot">
{% if forecast.available %}<div class="forecast-y-axis" aria-hidden="true">{% for value in e1rm_scale %}<span>{{ value }}</span>{% endfor %}</div>{% endif %}
<svg class="forecast-chart" tabindex="0" role="group" aria-label="Exercise history and forecast. Use left and right arrows to inspect dates." viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none">
{% if forecast_enabled and forecast.available %}
<path d="{{ path_band(forecast_band_lower, forecast_band_upper, vb_height) }}" fill="#10b981" fill-opacity="0.10" stroke="none"></path>
<path d="{{ path(forecast_points, vb_height) }}" stroke="#059669" stroke-width="2" vector-effect="non-scaling-stroke" stroke-dasharray="5,4" fill="none"></path>
{{ forecast_circles(forecast_points, forecast_details, '#059669') }}
<line x1="{{ ((forecast_start_position * vb_width) + margin) | int }}" y1="{{ margin }}" x2="{{ ((forecast_start_position * vb_width) + margin) | int }}" y2="{{ vb_height + margin }}" stroke="#059669" stroke-width="0.8" stroke-dasharray="2,2" stroke-opacity="0.5"></line>
{% endif %}
{% for plot in plots %}
<g class="{{ plot.label }} {% if forecast.available and plot.label != 'E1RM' %}hidden{% endif %}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
{{ plot_line(plot.points, plot.color) }}
</g>
{% endfor %}
<g style="fill-opacity: 0%">
{% 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 %}
<rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}"
class="pnt-{{ unique_id }}" data-msg="{{ message }}"></rect>
{% endfor %}
</g>
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none"
stroke-opacity="60%" />
</svg>
<div class="flex justify-center pt-2">
{% for plot in plots %}
<div class="flex items-center px-2 select-none cursor-pointer" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
<line class="forecast-cursor" x1="{{ default_detail.position * vb_width + margin }}" x2="{{ default_detail.position * vb_width + margin }}" y1="0" y2="{{ vb_height + margin }}" stroke="#64748b" stroke-width="0.5" stroke-dasharray="2,2" pointer-events="none"/>
<g fill="transparent">
{% for detail in hover_details %}
<rect x="{{ detail.hit_start * vb_width + margin }}" y="0"
width="{{ (detail.hit_end - detail.hit_start) * vb_width }}" height="{{ vb_height + margin }}"
class="forecast-hit"
data-position="{{ detail.position * vb_width + margin }}"
data-kind="{{ detail.kind }}" data-date="{{ detail.date }}"
data-e1rm="{{ detail.e1rm }}" data-topset="{{ detail.top_set }}" data-range="{{ detail.range }}">
<title>{{ detail.date }} · {{ detail.top_set }} · {{ detail.e1rm }} E1RM</title>
</rect>
{% endfor %}
</g>
{% if not forecast_enabled %}
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none" stroke-opacity="60%" pointer-events="none"/>
{% endif %}
</svg>
</div>
<div class="forecast-axis"><span>{{ min_date.strftime('%d %b %Y') }}</span><span>{{ (forecast.end_date if forecast.available else max_date).strftime('%d %b %Y') }}</span></div>
<div class="forecast-readout" aria-label="Selected chart reading">
<div class="forecast-readout-heading"><span data-readout="kind">{{ default_detail.kind }}</span><time data-readout="date">{{ default_detail.date }}</time></div>
<div class="forecast-readout-values">
<div><span class="forecast-caption">Top set</span><strong data-readout="topset">{{ default_detail.top_set }}</strong></div>
<div><span class="forecast-caption">Est. 1RM</span><strong data-readout="e1rm">{{ default_detail.e1rm }}</strong></div>
<div><span class="forecast-caption">E1RM scenario range</span><strong data-readout="range">{{ default_detail.range }}</strong></div>
</div>
</div>
<p class="forecast-chart-hint">Hover or tap to inspect · focus chart and use ← → for dates{% if forecast.available %} · shaded area: plausible scenarios{% endif %}</p>
<div class="flex flex-wrap justify-center pt-2">
{% for plot in plots %}
<div class="flex items-center px-2 select-none cursor-pointer {% if forecast.available and plot.label != 'E1RM' %}line-through{% endif %}" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
on click toggle .hidden on my.plot_line then toggle .line-through on me">
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
<div class="text-xs">{{ plot.label }}</div>
</div>
{% endfor %}
</div>
{% endfor %}
{% if forecast_enabled and forecast.available %}
<div class="flex items-center px-2 select-none">
<div class="w-3 h-0.5 mr-1 border-t-2 border-dashed border-emerald-600"></div>
<div class="text-xs">Projected E1RM</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,59 @@
<div id="saved-queries-library" class="pt-10 border-t border-gray-100">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-gray-800">Saved Queries Library</h2>
<p class="text-sm text-gray-500 mt-1">Pin a table or graph to your Home dashboard.</p>
</div>
<span class="text-xs font-medium text-gray-400 uppercase tracking-widest">{{ saved_queries|length }} Queries Total</span>
</div>
{% if saved_queries %}
<div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
<div class="overflow-x-auto">
<table class="min-w-full table-zebra">
<thead class="bg-gray-50/50">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider border-b">Query Title</th>
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider border-b">Quick Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for saved in saved_queries %}
<tr class="group transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<button hx-get="{{ url_for('sql_explorer.load_sql_query', query_id=saved.id) }}" hx-target="#sql-query" class="flex items-center text-sm font-medium text-gray-900 hover:text-blue-600 group-hover:translate-x-1 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2.5 text-gray-400 group-hover:text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 3.057 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-3.057-9.542-7z" /></svg>
{{ saved.title or 'Untitled Query' }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<button hx-get="{{ url_for('sql_explorer.plot_query', query_id=saved.id) }}" hx-target="#sql-plot-results" class="text-green-600 hover:text-green-800 px-2 py-1 rounded-lg hover:bg-green-50 transition-colors text-sm" title="Visualize plot">Preview graph</button>
{% if saved.table_pinned %}
<button hx-delete="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"table"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-blue-700 bg-blue-50 hover:bg-blue-100 px-2 py-1 rounded-lg transition-colors text-sm" title="Unpin table">Table pinned</button>
{% else %}
<button hx-post="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"table"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-gray-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors text-sm" title="Pin table to Home">Pin table</button>
{% endif %}
{% if saved.graph_pinned %}
<button hx-delete="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"graph"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-purple-700 bg-purple-50 hover:bg-purple-100 px-2 py-1 rounded-lg transition-colors text-sm" title="Unpin graph">Graph pinned</button>
{% else %}
<button hx-post="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"graph"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-gray-600 hover:text-purple-700 px-2 py-1 rounded-lg hover:bg-purple-50 transition-colors text-sm" title="Pin graph to Home">Pin graph</button>
{% endif %}
<button hx-delete="{{ url_for('sql_explorer.delete_sql_query', query_id=saved.id) }}" hx-target="#sql-query" hx-confirm="Delete query '{{ saved.title }}'?" class="text-red-400 hover:text-red-600 p-1 rounded-lg hover:bg-red-50 transition-colors" title="Delete query">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5-4h4a2 2 0 012 2v1H7V5a2 2 0 012-2z" /></svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="text-center py-12 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
<h3 class="mt-2 text-sm font-medium text-gray-900">No saved queries</h3>
<p class="mt-1 text-sm text-gray-500">Create and save a query to pin it to Home.</p>
</div>
{% endif %}
</div>
@@ -140,8 +140,11 @@
<!-- Plot Results -->
<div id="sql-plot-results" class="transition-all duration-300"></div>
<!-- Saved Queries Section -->
<div class="pt-10 border-t border-gray-100">
{{ render_partial('partials/sql_explorer/saved_queries.html', saved_queries=saved_queries) }}
<!-- Legacy Saved Queries Section (kept hidden for compatibility while the partial is adopted) -->
<div class="hidden">
<div class="pt-10 border-t border-gray-100">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">Saved Queries Library</h2>
<span class="text-xs font-medium text-gray-400 uppercase tracking-widest">{{ saved_queries|length }} Queries
@@ -220,5 +223,6 @@
<p class="mt-1 text-sm text-gray-500">Get started by creating and saving your first SQL query.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
@@ -2,7 +2,7 @@
{% set unique_id = range(1000, 9999) | random %} {# Simple unique ID for elements #}
<div class="sql-plot-container p-4 border rounded bg-white shadow" id="sql-plot-{{ unique_id }}">
<h4 class="text-lg font-semibold text-gray-700 text-center mb-2">{{ title }}</h4>
{% if show_title|default(true) %}<h4 class="text-lg font-semibold text-gray-700 text-center mb-2">{{ title }}</h4>{% endif %}
{% if plot_type == 'table' %}
{# Fallback to rendering a table if plot type is not supported or data is unsuitable #}
@@ -122,4 +122,4 @@
</svg>
</div>
{% endif %}
</div>
</div>
+8 -8
View File
@@ -1,12 +1,12 @@
<tr id="topset-{{ topset_id }}">
<tr class="workout-set-row" id="topset-{{ topset_id }}">
<td class="p-0 sm:p-4 text-sm font-semibold text-gray-900 break-normal">
{% if is_edit|default(false, true) == false %}
<div class="flex items-center space-x-2">
<div class="workout-exercise-name">
<span class="cursor-pointer" hx-get="{{ url_for('tags.goto_tag') }}"
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name }}</span>
<div class="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-1">
<div class="workout-exercise-tools">
<button
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
title="Show Progress Graph"
@@ -17,7 +17,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
<span class="sr-only">Show Progress Graph</span>
<span class="workout-tool-label">Progress</span>
</button>
<button
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
@@ -29,7 +29,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
<span class="sr-only">Show History</span>
<span class="workout-tool-label">History</span>
</button>
</div>
</div>
@@ -57,11 +57,11 @@
<div class="flex items-center flex-col sm:flex-row">
<input type="number"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-4 sm:pl-10 p-2.5 "
name="repetitions" value="{{ repetitions }}">
aria-label="Repetitions" name="repetitions" value="{{ repetitions }}">
<p class="px-2">x</p>
<input type="number"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-4 sm:pl-10 p-2.5 "
name="weight" value="{{ weight }}">
aria-label="Weight in kilograms" step="any" name="weight" value="{{ weight }}">
<p class="px-2">kg</p>
</div>
{% endif %}
@@ -118,7 +118,7 @@
</td>
</tr>
{# Target row modified for dismissible extra content (graph or history) #}
<tr id="extra-target-{{ topset_id }}">
<tr id="extra-target-{{ topset_id }}" {% if existing_row|default(false) or is_edit|default(false) %}hx-swap-oob="outerHTML"{% endif %}>
<td colspan="3" class="p-0 relative">
<div id="extra-content-{{ topset_id }}" class="extra-content-container" _="
on htmx:afterSwap
+12 -58
View File
@@ -1,64 +1,18 @@
{% set distribution = distribution or muscle_distribution %}
{% set category_name = category_name or 'Muscle Group' %}
{% set distribution = distribution|default([], true) %}
{% set category_name = category_name|default('Muscle Group', true) %}
{% set breakdown_id = category_name.lower().replace(' ', '-') %}
{% if distribution %}
<div class="px-4 py-3 bg-white relative" id="{{ breakdown_id }}-breakdown"
<div class="workout-breakdown" id="{{ breakdown_id }}-breakdown"
hx-get="{{ url_for('workout.get_workout_distribution', person_id=person_id, workout_id=workout_id, category=category_name) }}"
hx-trigger="topsetAdded from:body" hx-swap="outerHTML">
<!-- Shared Popover Container (managed by Hyperscript) -->
<div id="popover-{{ breakdown_id }}"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 hidden bg-white border border-gray-200 shadow-2xl rounded-xl p-4 min-w-[200px] animate-in fade-in zoom-in duration-200"
_="on click from elsewhere add .hidden to me">
<div id="content-{{ breakdown_id }}"></div>
<button class="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
_="on click add .hidden to #popover-{{ breakdown_id }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
hx-trigger="topsetAdded from:body, topsetChanged from:body" hx-swap="outerHTML">
<h3>{{ category_name }}</h3>
{% if distribution %}
<div class="workout-distribution-bar" aria-hidden="true">
{% for item in distribution %}<span style="width: {{ item.percentage }}%; background-color: {{ item.color }};"></span>{% endfor %}
</div>
<div class="flex items-center justify-between mb-2">
<h4 class="text-[9px] font-black text-gray-400 uppercase tracking-[0.25em]">{{ category_name }} Distribution
</h4>
</div>
<!-- Sleek Performance Bar -->
<div class="w-full h-8 flex overflow-hidden rounded-lg bg-gray-100 shadow-inner p-0.5 cursor-pointer">
<ul class="workout-distribution-legend">
{% for item in distribution %}
<div class="h-full transition-all duration-700 ease-in-out flex items-center justify-center relative group first:rounded-l-md last:rounded-r-md"
style="width: {{ item.percentage }}%; background-color: {{ item.color }};" _="on click
halt the event
put '<div class=\'flex flex-col gap-1\'>
<div class=\'flex items-center gap-2\'>
<div class=\'w-3 h-3 rounded-full\' style=\'background-color: {{ item.color }}\'></div>
<span class=\'font-black text-sm uppercase\'>{{ item.attribute_name }}</span>
</div>
<div class=\'text-2xl font-black text-gray-900\'>{{ item.percentage }}%</div>
<div class=\'text-[10px] font-bold text-gray-400 uppercase tracking-wider\'>{{ item.count }} Sets Targeted</div>
</div>' into #content-{{ breakdown_id }}
remove .hidden from #popover-{{ breakdown_id }}"
title="{{ item.attribute_name }}: {{ item.percentage }}%">
<!-- Labels (Name & Percentage grouped and centered) -->
<div
class="flex items-center justify-center gap-1.5 leading-none text-white pointer-events-none px-1 overflow-hidden">
{% if item.percentage > 18 %}
<span class="text-[9px] font-black uppercase tracking-tighter truncate drop-shadow-sm">{{
item.attribute_name }}</span>
<span class="text-[9px] font-bold opacity-90 whitespace-nowrap drop-shadow-sm">{{ item.percentage
}}%</span>
{% elif item.percentage > 8 %}
<span class="text-[9px] font-black drop-shadow-sm">{{ item.percentage }}%</span>
{% endif %}
</div>
<!-- Enhanced Hover State -->
<div class="absolute inset-0 bg-white/15 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<li><span class="workout-legend-label"><i style="background-color: {{ item.color }};" aria-hidden="true"></i>{{ item.attribute_name }}</span><span title="{{ item.count }} sets targeted">{{ item.percentage }}%</span></li>
{% endfor %}
</div>
</ul>
{% else %}<p class="workout-subtle">No categorized sets yet.</p>{% endif %}
</div>
{% endif %}
+14
View File
@@ -0,0 +1,14 @@
{% set totals = namespace(reps=0) %}
{% for top_set in top_sets %}{% set totals.reps = totals.reps + (top_set.repetitions or 0)|int %}{% endfor %}
<section class="workout-card workout-summary" id="workout-summary"
aria-label="Session summary" aria-live="polite"
hx-get="{{ url_for('workout.get_workout_summary', person_id=person_id, workout_id=workout_id) }}"
hx-trigger="topsetAdded from:body, topsetChanged from:body" hx-swap="outerHTML">
<p class="workout-eyebrow">This session</p>
<dl>
<div><dt>Exercises</dt><dd>{{ top_sets|map(attribute='exercise_id')|unique|list|length }}</dd></div>
<div><dt>Top sets</dt><dd>{{ top_sets|length }}</dd></div>
<div><dt>Reps</dt><dd>{{ totals.reps }}</dd></div>
</dl>
<p class="workout-subtle">Based on your logged top sets.</p>
</section>
@@ -0,0 +1,28 @@
<div id="workout-tag-editor-{{ workout_id }}" class="workout-tag-editor">
<form hx-post="{{ url_for('tags.add_tag_to_workout', workout_id=workout_id, view='session') }}"
hx-trigger="change" hx-target="#workout-tag-editor-{{ workout_id }}" hx-swap="outerHTML"
hx-disabled-elt="#workout-tag-editor-{{ workout_id }} input, #workout-tag-editor-{{ workout_id }} button"
_="on htmx:responseError remove .hidden from #tag-error-{{ workout_id }}">
<fieldset>
<legend class="workout-subtle">Select tags for this session</legend>
<div class="workout-tag-choices">
{% for tag in tags %}
<label class="workout-tag-choice">
<input id="workout-{{ workout_id }}-tag-{{ tag.tag_id }}" type="checkbox" name="tag_id" value="{{ tag.tag_id }}" {% if tag.is_selected %}checked{% endif %}>
<span>{{ tag.tag_name }}</span>
</label>
{% else %}<p class="workout-subtle">Create your first tag below.</p>{% endfor %}
</div>
</fieldset>
</form>
<form class="workout-tag-create"
hx-post="{{ url_for('tags.create_new_tag_for_workout', workout_id=workout_id, view='session') }}"
hx-target="#workout-tag-editor-{{ workout_id }}" hx-swap="outerHTML"
hx-disabled-elt="#workout-tag-editor-{{ workout_id }} input, #workout-tag-editor-{{ workout_id }} button"
_="on htmx:responseError remove .hidden from #tag-error-{{ workout_id }}">
<label for="new-workout-tag-{{ workout_id }}" class="sr-only">New tag name</label>
<input id="new-workout-tag-{{ workout_id }}" type="text" name="tag_name" placeholder="New tag…" required>
<button type="submit" class="workout-button">Add tag</button>
</form>
<p id="tag-error-{{ workout_id }}" class="workout-editor-error hidden" role="alert">Could not save tags. Please try again.</p>
</div>
+4 -73
View File
@@ -1,73 +1,4 @@
<div class="flex">
<div id="tag-wrapper-w-{{ workout_id }}">
{{ render_partial('partials/workout_tags_list.html', tags=tags)}}
</div>
<span id="tag-form-show-w-{{ workout_id }}" class="cursor-pointer" _="on click
remove .hidden from #tag-form-w-{{ workout_id }}
then remove .hidden from #tag-form-hide-w-{{ workout_id }}
then add .hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6" data-darkreader-inline-stroke="" style="--darkreader-inline-stroke: currentColor;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path>
</svg>
</span>
<span id="tag-form-hide-w-{{ workout_id }}" _="on click
add .hidden to #tag-form-w-{{ workout_id }}
then remove .hidden from #tag-form-show-w-{{ workout_id }}
then add .hidden" class="cursor-pointer hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
</svg>
</span>
</div>
<div id="tag-form-w-{{ workout_id }}" class="hidden pt-2 pb-3">
<span class="text-base font-normal text-gray-500">Add tag to workout</span>
<div class="flex pt-2 flex-col sm:flex-row pb-2">
<div class="w-full md:w-1/2 px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-state">
Tag
</label>
<div class="relative">
<div class="w-full">
{{ render_partial('partials/custom_select.html',
name='tag_id',
options=tags,
multiple=true,
search=true,
placeholder='Select tags',
hx_post=url_for('tags.add_tag_to_workout', workout_id=workout_id),
hx_target='#tag-wrapper-w-' + workout_id|string)
}}
</div>
</div>
</div>
<div class="w-full md:w-1/2 px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
New tag name
</label>
<div class="flex">
<input
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
type="text" name="tag_name">
<button type="submit" hx-post="{{ url_for('tags.create_new_tag_for_workout', workout_id=workout_id) }}"
hx-include="[name='tag_name']" hx-target="#tag-wrapper-w-{{ workout_id }}"
class="p-2.5 ml-2 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6" data-darkreader-inline-stroke=""
style="--darkreader-inline-stroke: currentColor;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path>
</svg>
<span class="sr-only">Search</span>
</button>
</div>
</div>
</div>
</div>
<section class="workout-card workout-tags-card" aria-labelledby="workout-tags-heading-{{ workout_id }}">
<h2 id="workout-tags-heading-{{ workout_id }}">Tags</h2>
{{ render_partial('partials/workout_tag_editor.html', person_id=person_id, workout_id=workout_id, tags=tags) }}
</section>
+54 -100
View File
@@ -1,9 +1,8 @@
{% extends 'base.html' %}
{% block content %}
<nav class="flex md:pb-0 pb-4" aria-label="Breadcrumb">
<div class="workout-workspace">
<nav class="flex md:pb-0 pb-4" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse">
<li class="inline-flex items-center">
<a hx-get="{{ url_for('dashboard') }}" hx-push-url="true" hx-target="#container"
@@ -23,8 +22,8 @@
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m1 9 4-4-4-4" />
</svg>
{% set hx_current_url = request.headers.get('HX-Current-URL') %}
<a hx-get="{{ hx_current_url if hx_current_url else url_for('calendar.get_calendar', person_id=person_id) }}"
<a hx-get="{{ url_for('calendar.get_calendar', person_id=person_id) }}"
hx-push-url="true" hx-target="#container"
class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2 dark:text-gray-400 dark:hover:text-white cursor-pointer">{{person_name}}</a>
</div>
@@ -41,107 +40,62 @@
</li>
</ol>
</nav>
<header class="workout-header">
<div>
<p class="workout-eyebrow">Training log</p>
<h1>{{ person_name }}<span> / Workout</span></h1>
{{ render_partial('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date) }}
</div>
<button type="button" class="workout-button workout-delete"
hx-get="{{ url_for('workout.delete_workout', person_id=person_id, workout_id=workout_id) }}"
hx-confirm="Are you sure you wish to delete this workout?" hx-push-url="true" hx-target="#container">Delete workout</button>
</header>
<div class='p-0 md:p-4 m-0 md:m-2'>
<div class="relative w-full h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow overflow-hidden">
<!-- Modal header -->
<div class="p-4 md:p-6 border-b relative">
<div class="flex flex-col w-full pr-8">
<h3 class="text-2xl font-black text-gray-900 leading-tight">{{ person_name }}</h3>
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id,
tags=tags) }}
<div class="workout-layout">
<div class="workout-main">
<section class="workout-card workout-logger" aria-labelledby="log-heading">
<div class="workout-section-heading">
<div><p class="workout-eyebrow">Log your training</p><h2 id="log-heading">Add a top set</h2></div>
<span class="workout-subtle">Reps &times; weight</span>
</div>
{{ render_partial('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, has_value=False) }}
</section>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{{ render_partial('partials/start_date.html', person_id=person_id, workout_id=workout_id,
start_date=start_date) }}
{{ render_partial('partials/workout_note.html', person_id=person_id, workout_id=workout_id,
note=note) }}
</div>
<section class="workout-card workout-results" aria-labelledby="sets-heading">
<div class="workout-section-heading">
<div><h2 id="sets-heading">Session exercises</h2><p class="workout-subtle">Your top sets, records and progress.</p></div>
</div>
<table class="workout-table">
<thead><tr><th scope="col">Exercise</th><th scope="col">Top set</th><th scope="col"><span class="sr-only">Actions</span></th></tr></thead>
<tbody id="new-workout" hx-target="closest tr" hx-swap="outerHTML swap:0.5s">
{% for top_set in top_sets %}
{{ render_partial('partials/topset.html', person_id=person_id, workout_id=workout_id, **top_set) }}
{% endfor %}
</tbody>
</table>
<div class="workout-empty" id="no-workouts"><strong>Your session starts here</strong><p>Add your first top set above. Progress and records will appear here.</p></div>
<div id="exercise-progress"></div>
</section>
</div>
<button type="button"
class="absolute top-4 right-4 text-gray-400 bg-transparent hover:bg-gray-100 hover:text-gray-900 rounded-lg text-sm p-2 transition-colors inline-flex items-center"
hx-get="{{ url_for('workout.delete_workout', person_id=person_id, workout_id=workout_id) }}"
hx-confirm="Are you sure you wish to delete this workout?" hx-push-url="true"
hx-target="#container">
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg>
<span class="sr-only">Delete workout</span>
</button>
</div>
<!-- Multi-Category Breakdown Stripes -->
<div class="bg-gray-50/50 border-b divide-y divide-gray-100">
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
distribution=muscle_distribution, category_name='Muscle Group') }}
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
distribution=equipment_distribution, category_name='Machine vs Free Weight') }}
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
distribution=movement_distribution, category_name='Compound vs Isolation') }}
</div>
<!-- Workout Content Card -->
<div class="p-0">
<div class="p-2 md:p-4 border-0 md:border-b">
<!-- Modal footer / New Set Form -->
<div class="flex items-center p-1 md:p-2 rounded-b">
{{ render_partial('partials/new_set_form.html', person_id=person_id,
workout_id=workout_id,
exercises=exercises,
has_value=False) }}
</div>
<!-- Modal body / Top Sets Table -->
<div class="p-3 md:p-6 space-y-6">
<table class="items-center w-full bg-transparent border-collapse table-fixed">
<thead>
<tr>
<th
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
Exercise</th>
<th
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
Top Set</th>
<th
class="bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap w-9 sm:w-20">
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100" id="new-workout" hx-target="closest tr"
hx-swap="outerHTML swap:0.5s">
{% for top_set in top_sets %}
{{ render_partial('partials/topset.html', person_id=person_id,
workout_id=workout_id, **top_set) }}
{% endfor %}
</tbody>
</table>
{% if top_sets|length == 0 %}
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert"
id="no-workouts">
No sets recorded for this session.
</div>
{% endif %}
</div>
<div id="exercise-progress" class="mx-0 md:mx-5">
</div>
<aside class="workout-sidebar" aria-label="Session information">
{{ render_partial('partials/workout_summary.html', person_id=person_id, workout_id=workout_id, top_sets=top_sets) }}
<details class="workout-card workout-disclosure" open>
<summary>Training balance <span class="workout-subtle">Muscles & equipment</span></summary>
<div class="workout-disclosure-body">
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id, distribution=muscle_distribution, category_name='Muscle Group') }}
<details class="workout-secondary-details">
<summary>Equipment & movement</summary>
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id, distribution=equipment_distribution, category_name='Machine vs Free Weight') }}
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id, distribution=movement_distribution, category_name='Compound vs Isolation') }}
</details>
</div>
</div>
</div>
</details>
{{ render_partial('partials/session_note.html', person_id=person_id, workout_id=workout_id, note=note or '') }}
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id, tags=tags) }}
</aside>
</div>
</div>
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load"
hx-target="#stats" hx-swap="innerHTML">
</div>
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load" hx-target="#stats" hx-swap="innerHTML"></div>
{% endblock %}
+139
View File
@@ -0,0 +1,139 @@
import unittest
from datetime import date, timedelta
import numpy as np
from forecasting import (TransitionModel, build_forecast, e1rm, profile,
sessions, training_blocks)
from utils import get_exercise_graph_model, _format_kg
def rows(pairs, start=date(2026, 1, 1), interval=7):
return tuple((start + timedelta(days=i * interval), w, r) for i, (w,r) in enumerate(pairs))
def forecast(data, weeks=8):
return build_forecast([], [r[0] for r in data], weeks,
weights=[r[1] for r in data], repetitions=[r[2] for r in data])
class ForecastTests(unittest.TestCase):
def setUp(self):
self.history = rows([(70,7),(70,8),(70,9),(70.5,7),(70.5,8),(70.5,9),
(71,5),(71,6),(71,6),(71,6),(71,6),(71,7)])
def test_fractional_loads_round_trip(self):
self.assertAlmostEqual(float(e1rm(93.5,7)),113.1942,places=3)
f=forecast(self.history)
for point in f['points']:
self.assertEqual(point['value'],round(float(e1rm(point['projected_weight'], point['projected_repetitions'])),1))
self.assertEqual(_format_kg(96.75),'96.75')
def test_stalls_do_not_force_weekly_load_increases(self):
m=TransitionModel(self.history)
self.assertEqual(m.profile['block_stalls'],3)
self.assertEqual(m.profile['ceiling'],9)
self.assertEqual(m.next_set()[0][0],(71,7))
w,r,p=m.outcomes([71],[7],[0])
self.assertTrue(np.all(p[w>71]==0))
def test_loading_at_ceiling_uses_recorded_step(self):
m=TransitionModel(self.history)
w,r,p=m.outcomes([71],[9],[0])
self.assertTrue(np.any((w>71)&(p>0)))
self.assertLessEqual(float(w.max()),71.5)
def test_plateau_does_not_invent_gains(self):
f=forecast(rows([(50,8)]*16),52)
self.assertTrue(all(p['projected_weight']==50 and p['projected_repetitions']==8 for p in f['points']))
def test_mixed_observed_load_steps_are_preserved(self):
m = TransitionModel(rows([(100,7),(100.5,7),(101.5,7),(102,7),(104,7)]))
w, r, p = m.outcomes([104], [7], [0])
increases = set(np.round(w[(w > 104) & (p > 0)] - 104, 3))
self.assertEqual(increases, {0.5, 1.0, 2.0})
def test_ceiling_does_not_turn_rep_gains_into_stall_evidence(self):
m = TransitionModel(rows([(100,6),(100,7),(101,6),(101,7),(102,6),(102,7)]))
w, r, p = m.outcomes([102], [7], [0])
# At the ceiling only the explicit prior can repeat this set;
# the observed rep-building transitions contribute no probability.
self.assertAlmostEqual(float(p[(w == 102) & (r == 7)].sum()), float(p[0,0]))
self.assertTrue(np.any((w > 102) & (p > 0)))
w, r, p = m.outcomes([102], [6], [0])
self.assertTrue(np.any((w == 102) & (r == 7) & (p > 0)))
def test_large_load_jump_is_not_reused_as_routine_progression(self):
m = TransitionModel(rows([(50,7),(50.5,7),(51,7),(100,7)]))
w, r, p = m.outcomes([100], [7], [0])
self.assertEqual(set(w[(w > 100) & (p > 0)]), {100.5})
def test_months_off_excludes_old_performance(self):
older=rows([(150,10)]*12,start=date(2024,1,1))
fresh=rows([(50,6),(50,7),(50,7),(50,8)], start=date(2026,1,1))
f=forecast(older+fresh)
expected=forecast(fresh)
self.assertEqual(f['points'],expected['points'])
self.assertEqual(f['training_blocks'],2)
self.assertEqual(f['observed_points'],4)
self.assertFalse(forecast(older+fresh[:3])['available'])
def test_block_detection_is_prefix_invariant(self):
data=self.history+rows([(60,8)]*10,start=date(2027,1,1))
full=training_blocks(data)
for cutoff in range(1,len(data)+1):
trimmed=tuple(tuple(r for r in b if r[0]<=data[cutoff-1][0]) for b in full if b[0][0]<=data[cutoff-1][0])
self.assertEqual(training_blocks(data[:cutoff]),trimmed)
def test_duplicate_date_uses_one_coherent_best_set(self):
extra=(self.history[-1][0],65,5)
data=self.history+(self.history[-1],extra)
self.assertEqual(forecast(data)['points'],forecast(self.history)['points'])
def test_nonfinite_and_unsupported_data(self):
invalid=rows([(float('nan'),8),(30,40),(0,6),(float('inf'),6)])
self.assertFalse(forecast(invalid)['available'])
def test_observed_anchor_and_consistent_horizons(self):
small,large=forecast(self.history,4),forecast(self.history,52)
self.assertEqual(small['points'][0]['projected_weight'],71)
self.assertEqual(small['points'][0]['projected_repetitions'],7)
self.assertEqual(small['next_session'],large['next_session'])
for a,b in zip(small['session_points'],large['session_points']):
self.assertEqual(a,b)
def test_interval_order_and_cadence(self):
data=rows([(50,6),(50,7),(50,7),(50,8),(50,8),(50,9)],interval=14)
f=forecast(data,8)
self.assertEqual(f['profile']['cadence_days'],14)
self.assertEqual(len(f['session_points']),4)
self.assertEqual(f['next_session']['date'],data[-1][0]+timedelta(days=14))
for point in f['points']:
self.assertLessEqual(point['lower'],point['value'])
self.assertLessEqual(point['value'],point['upper'])
def test_cached_forecast_is_not_mutated_by_renderer(self):
f=forecast(self.history)
f['points'][0]['value']=-1
self.assertGreater(forecast(self.history)['points'][0]['value'],0)
def test_display_window_does_not_change_training_or_seed_reps(self):
def graph(visible, enabled=True):
return get_exercise_graph_model('Test', [round(float(e1rm(w,r)),1) for _,w,r in visible],
[r for _,_,r in visible], [w for _,w,_ in visible], [d for d,_,_ in visible], ['']*len(visible),
'All',1,1,forecast_enabled=enabled,forecast_training_rows=[dict(start_date=d,weight=w,repetitions=r) for d,w,r in self.history])
full,short=graph(self.history),graph(self.history[-3:])
# Equivalent rep tables intentionally reflect the visible rep range.
for a,b in zip(full['forecast']['points'],short['forecast']['points']):
self.assertEqual({k:v for k,v in a.items() if k!='top_sets'},
{k:v for k,v in b.items() if k!='top_sets'})
self.assertEqual(full['forecast']['target_weight_increment'],short['forecast']['target_weight_increment'])
self.assertIsNone(graph(self.history,False)['forecast_target_reps'])
hits=full['hover_details']
for a,b in zip(hits,hits[1:]):
self.assertEqual(a['hit_end'],b['hit_start'])
self.assertLess(a['hit_start'],a['hit_end'])
if __name__=='__main__':
unittest.main()
+194
View File
@@ -0,0 +1,194 @@
"""Workout rendering and HTMX lifecycle checks, using an isolated database."""
import unittest
from datetime import date
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
class WorkoutViewTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Import the application without opening a real database connection.
with patch('psycopg_pool.ConnectionPool'):
import app
cls.module = app
cls.app = app.app
cls.app.config.update(TESTING=True)
def setUp(self):
import routes.workout as workout
self.workout = workout
self.db = MagicMock()
for module in (self.module, workout):
patcher = patch.object(module, 'db', self.db)
patcher.start()
self.addCleanup(patcher.stop)
self.user = SimpleNamespace(id=1, name='Peter', is_authenticated=True,
is_active=True, is_anonymous=False,
is_admin=False, get_id=lambda: '1')
loader = patch.object(self.module.login_manager, '_user_callback', lambda _: self.user)
loader.start()
self.addCleanup(loader.stop)
self.client = self.app.test_client()
with self.client.session_transaction() as session:
session['_user_id'] = '1'
session['_fresh'] = True
self.top_sets = [
dict(topset_id=i, exercise_id=i, exercise_name=name, repetitions=reps, weight=weight)
for i, (name, reps, weight) in enumerate([
('30 degrees Smith Press', 7, 93.5),
('Seated Smith machine BTN press', 5, 61.5),
('Smith machine JM press', 12, 62.0),
('DB Lateral Raises', 20, 10.5),
('Smith machine Bulgarian split squat', 12, 96.0),
], 1)
]
self.distribution = [
dict(attribute_name=name, percentage=percent, color=color, count=1)
for name, percent, color in [('Shoulders', 40, '#477db5'), ('Chest', 20, '#299e9a'),
('Quads', 20, '#8b75bb'), ('Triceps', 20, '#c78565')]
]
self.view_model = dict(person_id=1, workout_id=1, person_name='Peter',
start_date=date(2026, 9, 5), note='', tags=[], top_sets=self.top_sets,
muscle_distribution=self.distribution, equipment_distribution=[], movement_distribution=[])
model = patch.object(workout, '_get_workout_view_model', lambda *args: self.view_model)
model.start()
self.addCleanup(model.stop)
self.db.execute.return_value = self.top_sets
self.db.get_topset.return_value = self.top_sets[0]
self.db.get_exercise.return_value = {'name': self.top_sets[0]['exercise_name']}
self.db.get_all_exercises.return_value = [{'exercise_id': 1, 'name': 'Smith Press'}]
self.db.create_topset.return_value = 6
self.db.get_topset_achievements.return_value = dict(is_pr_reps=True, rep_increase=1, weight_increase=0, stalled_sessions=0)
self.db.exercises.get_workout_attribute_distribution.return_value = self.distribution
self.base = '/person/1/workout/1'
def test_full_page_and_fragment_render(self):
for headers in ({}, {'HX-Request': 'true'}):
response = self.client.get(self.base, headers=headers)
self.assertEqual(response.status_code, 200)
self.assertIn(b'workout-layout', response.data)
self.assertIn(b'Session exercises', response.data)
self.assertEqual(response.data.count(b'id=extra-target-1'), 1)
def test_summary_counts_unique_exercises_and_refreshes(self):
self.db.execute.return_value = self.top_sets + [self.top_sets[0]]
response = self.client.get(self.base + '/summary')
self.assertEqual(response.status_code, 200)
self.assertIn(b'<dt>Exercises<dd>5', response.data)
self.assertIn(b'<dt>Top sets<dd>6', response.data)
self.assertIn(b'<dt>Reps<dd>63', response.data)
self.assertIn(b'topsetChanged from:body', response.data)
def test_empty_distribution_keeps_refresh_target(self):
from flask import render_template
with self.app.test_request_context():
html = render_template('partials/workout_breakdown.html', person_id=1, workout_id=1,
distribution=[], category_name='Muscle Group')
self.assertIn('id="muscle-group-breakdown"', html)
self.assertIn('No categorized sets yet.', html)
self.assertIn('topsetAdded from:body', html)
def test_previous_performance_is_loaded_on_expansion(self):
self.db.get_most_recent_topset_for_exercise.return_value = (7, 93.5, 'Smith Press')
self.db.get_recent_topsets_for_exercise.return_value = [self.top_sets[0]]
response = self.client.get(self.base + '/exercise/most_recent_topset_for_exercise?exercise_id=1')
self.assertEqual(response.status_code, 200)
self.assertIn(b'toggle once from:closest details', response.data)
self.assertNotIn(b'hx-trigger=load', response.data)
self.assertIn(b'Previous performance', response.data)
self.assertNotIn(b'workout-stall-badge', response.data)
def test_previous_performance_shows_consecutive_stall_count(self):
self.db.get_most_recent_topset_for_exercise.return_value = (7, 93.5, 'Smith Press')
self.db.get_recent_topsets_for_exercise.return_value = [self.top_sets[0]]
for count, label in ((1, b'Stalled 1 time'), (3, b'Stalled 3 times')):
self.db.get_topset_achievements.return_value = {'stalled_sessions': count}
response = self.client.get(self.base + '/exercise/most_recent_topset_for_exercise?exercise_id=1')
self.assertEqual(response.status_code, 200)
self.assertIn(label, response.data)
self.assertIn('7 × 93.5 kg', response.get_data(as_text=True))
self.db.get_topset_achievements.assert_called_with(1)
def test_previous_performance_without_history_hides_stall_badge(self):
self.db.get_most_recent_topset_for_exercise.return_value = (None, None, 'Smith Press')
response = self.client.get(self.base + '/exercise/most_recent_topset_for_exercise?exercise_id=1')
self.assertEqual(response.status_code, 200)
self.assertNotIn(b'workout-stall-badge', response.data)
self.db.get_recent_topsets_for_exercise.assert_not_called()
def test_create_edit_cancel_update_and_delete_targets(self):
data = dict(exercise_id=1, repetitions=8, weight=93.5)
created = self.client.post(self.base + '/topset', data=data)
self.assertEqual(created.headers['HX-Trigger'], 'topsetAdded')
self.assertNotIn(b'hx-swap-oob', created.data)
for suffix in ('/topset/1/edit_form', '/topset/1'):
response = self.client.get(self.base + suffix)
self.assertEqual(response.status_code, 200)
self.assertIn(b'hx-swap-oob=outerHTML', response.data)
updated = self.client.put(self.base + '/topset/1', data=data)
self.assertEqual(updated.headers['HX-Trigger'], 'topsetChanged')
self.assertIn(b'hx-swap-oob=outerHTML', updated.data)
deleted = self.client.delete(self.base + '/topset/1/delete')
self.assertEqual(deleted.headers['HX-Trigger'], 'topsetChanged')
self.assertIn(b'hx-swap-oob=delete', deleted.data)
def test_summary_requires_owner(self):
with self.client.session_transaction() as session:
session.clear()
response = self.client.get(self.base + '/summary')
self.assertEqual(response.status_code, 302)
self.db.execute.assert_not_called()
def test_session_note_editor_round_trip_keeps_integrated_card(self):
import routes.notes as notes
text = 'Good energy\nKeep <cues> simple'
with patch.object(notes, 'db', self.db), patch.object(notes, '_fetch_workout_note', return_value=text), patch.object(notes, '_update_workout_note_for_person') as save:
edit = self.client.get(self.base + '/note/edit?view=session')
self.assertIn(b'workout-note-editor', edit.data)
# The HTML minifier can unescape RCDATA inside a textarea.
self.assertIn(b'Keep <cues> simple</textarea>', edit.data)
self.assertIn(b'Save note', edit.data)
saved = self.client.put(self.base + '/note?view=session', data={'note': text})
save.assert_called_once_with(1, 1, text)
self.assertIn(b'workout-note-trigger', saved.data)
self.assertIn(b'Good energy<br>Keep &lt;cues> simple', saved.data)
self.assertNotIn(b'<textarea', saved.data)
canceled = self.client.get(self.base + '/note?view=session')
self.assertIn(b'workout-note-preview', canceled.data)
legacy = self.client.get(self.base + '/note')
self.assertNotIn(b'workout-note-card', legacy.data)
def test_tags_return_updated_controls_after_select_clear_and_create(self):
import routes.tags as tags
tag = dict(tag_id=12, tag_name='Push', is_selected=True)
def execute(query, args=(), **kwargs):
if 'SELECT person_id' in query:
return {'person_id': 1}
if 'SELECT tag_id FROM Tag' in query:
return {'tag_id': 12}
if 'SELECT DISTINCT' in query:
return []
if 'FROM tag' in query:
return [tag]
return []
self.db.execute.side_effect = execute
with patch.object(tags, 'db', self.db):
for selected in ({'tag_id': '12'}, {}):
tag['is_selected'] = bool(selected)
response = self.client.post('/tag/workout/1/add?view=session', data=selected)
self.assertEqual(response.status_code, 200)
self.assertIn(b'workout-tag-editor-1', response.data)
self.assertIn(b'type=checkbox', response.data)
self.assertEqual(b' checked' in response.data, bool(selected))
tag['is_selected'] = True
response = self.client.post('/tag/workout/1/new?view=session', data={'tag_name': 'Push'})
self.assertEqual(response.status_code, 200)
self.assertIn(b'workout-tag-editor-1', response.data)
self.assertIn(b' checked', response.data)
if __name__ == '__main__':
unittest.main()
+240 -24
View File
@@ -1,11 +1,23 @@
import colorsys
from datetime import datetime, date, timedelta
from flask import request
import numpy as np
import numpy as np
from forecasting import build_forecast
import math
from decimal import Decimal
import math
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:.2f}".rstrip('0').rstrip('.')
def convert_str_to_date(date_str, format='%Y-%m-%d'):
try:
@@ -15,12 +27,132 @@ def convert_str_to_date(date_str, format='%Y-%m-%d'):
except TypeError:
return None
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):
# Precompute ranges
min_date, max_date = min(start_dates), max(start_dates)
total_span = (max_date - min_date).days or 1
min_e1rm, max_e1rm = min(estimated_1rm), max(estimated_1rm)
def infer_weight_increment(weights):
"""Infer a sensible display increment from the exercise's recorded weights."""
numeric_weights = sorted({round(float(weight), 4) for weight in weights if weight is not None})
differences = [
round(current - previous, 4)
for previous, current in zip(numeric_weights, numeric_weights[1:])
if current > previous
]
if not differences:
return 2.5
common_increments = [0.5, 1.0, 1.25, 2.0, 2.5, 5.0, 10.0]
smallest_difference = min(differences)
return min(common_increments, key=lambda increment: abs(increment - smallest_difference))
def _round_weight(value, increment, direction='nearest'):
if value is None:
return None
scaled = max(float(value), 0) / increment
if direction == 'floor':
rounded = math.floor(scaled) * increment
elif direction == 'ceil':
rounded = math.ceil(scaled) * increment
else:
rounded = round(scaled) * increment
return round(rounded, 2)
def infer_rep_targets(repetitions, buffer=5):
"""Return an expanded rep range based on the currently visible history."""
numeric_rep_values = [int(round(float(value))) for value in repetitions if value is not None]
numeric_reps = sorted(set(numeric_rep_values))
if not numeric_rep_values:
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 = min(20, max(1, observed_min - buffer))
target_max = min(20, observed_max + buffer)
target_reps = list(range(target_min, target_max + 1))
# Default to the latest recorded rep target so the first forecast comparison
# is like-for-like. The full expanded range remains available in the table.
default_reps = max(target_min, min(target_max, numeric_rep_values[-1]))
return target_reps, default_reps, observed_min, observed_max
def _top_set_estimates(e1rm, lower_e1rm, upper_e1rm, target_reps, increment):
estimates = []
for repetitions in target_reps:
conversion = (E1RM_BASE - E1RM_REP_FACTOR * repetitions) / 100
if conversion <= 0:
continue
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': 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, 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 = centered_reps if target_reps is None else int(target_reps)
except (TypeError, ValueError):
selected_reps = centered_reps
selected_reps = max(target_reps_list[0], min(target_reps_list[-1], selected_reps))
increment = forecast.get('profile', {}).get('increment') or infer_weight_increment(historical_weights)
for point in forecast['points']:
point['top_sets'] = _top_set_estimates(
point['value'], point['lower'], point['upper'], target_reps_list, increment
)
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(
(top_set for top_set in forecast['top_sets'] if top_set['repetitions'] == selected_reps),
None
)
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=None, forecast_training_rows=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)
if forecast_training_rows is None:
forecast_training_rows = [dict(start_date=d, weight=w, repetitions=r) for d, w, r in zip(start_dates, weight, repetitions)]
forecast = build_forecast(
[], [r['start_date'] for r in forecast_training_rows], forecast_weeks,
weights=[r['weight'] for r in forecast_training_rows],
repetitions=[r['repetitions'] for r in forecast_training_rows]
) 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
e1rm_values_for_scale = list(estimated_1rm)
if forecast.get('available'):
e1rm_values_for_scale.extend([point['lower'] for point in forecast['points']])
e1rm_values_for_scale.extend([point['upper'] for point in forecast['points']])
min_e1rm, max_e1rm = min(e1rm_values_for_scale), max(e1rm_values_for_scale)
min_reps, max_reps = min(repetitions), max(repetitions)
min_weight, max_weight = min(weight), max(weight)
@@ -34,17 +166,22 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
vb_height *= 75 / vb_height # Scale to 75px height
# Use NumPy arrays for efficient scaling
relative_positions = np.round(np.array([(date - min_date).days / total_span for date in start_dates]), 1)
relative_positions = np.round(np.array([(date - observed_min_date).days / total_span for date in start_dates]), 3)
estimated_1rm_scaled = np.round(((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height, 1)
repetitions_scaled = np.round(((np.array(repetitions) - min_reps) / reps_range) * vb_height, 1)
weight_scaled = np.round(((np.array(weight) - min_weight) / weight_range) * vb_height, 1)
# Calculate slope and line of best fit
slope_kg_per_day = e1rm_range / total_span
best_fit_formula = {
'kg_per_week': round(slope_kg_per_day * 7, 1),
'kg_per_month': round(slope_kg_per_day * 30, 1)
}
best_fit_formula = {
'kg_per_week': round(slope_kg_per_day * 7, 1),
'kg_per_month': round(slope_kg_per_day * 30, 1)
}
if forecast.get('available'):
best_fit_formula = {
'kg_per_week': forecast['weekly_change'],
'kg_per_month': round(forecast['weekly_change'] * 4.345, 1)
}
best_fit_points = []
try:
@@ -83,25 +220,104 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
}
# Prepare plot labels
plot_labels = list(zip(relative_positions.tolist(), messages))
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)
for point_index, point in enumerate(forecast['points']):
position = round((point['date'] - observed_min_date).days / total_span, 3)
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))
selected_top_set = point
if point_index == 0:
top_set_message = f" · Recorded {point['set_display']}"
forecast_details.append({
'position': position,
'date': point['date'].strftime('%d %b %y'),
'kind': 'Current reading',
'e1rm': f"{_format_kg(forecast['last_value'])}kg",
'top_set': point['set_display'],
'range': 'Observed value'
})
elif selected_top_set:
top_set_message = (
f" · Scenario {point['set_display']}"
)
forecast_details.append({
'position': position,
'date': point['date'].strftime('%d %b %y'),
'kind': 'Workout scenario' if point['date'] < forecast['end_date'] else 'Horizon scenario',
'e1rm': f"{_format_kg(point['value'])}kg",
'top_set': point['set_display'],
'range': f"{_format_kg(point['lower'])}–{_format_kg(point['upper'])}kg E1RM",
})
forecast_labels.append((
position,
f"{point['date'].strftime('%d %b %y')} · Projected E1RM: {point['value']}kg "
f"(range {point['lower']}–{point['upper']}kg){top_set_message}"
))
# Return exercise data with SVG dimensions and data points
# Non-overlapping nearest-date targets; no late forecast rectangle can
# cover an earlier date when a year is compressed into a small chart.
hover_by_position = {}
for detail in plot_details + forecast_details:
hover_by_position[detail['position']] = detail
hover_details = sorted(hover_by_position.values(), key=lambda d: d['position'])
for i, detail in enumerate(hover_details):
detail['hit_start'] = 0 if i == 0 else (hover_details[i-1]['position'] + detail['position']) / 2
detail['hit_end'] = 1 if i == len(hover_details)-1 else (hover_details[i+1]['position'] + detail['position']) / 2
# Return exercise data with SVG dimensions and data points
return {
'title': title,
'vb_width': vb_width,
'vb_height': vb_height,
'vb_height': vb_height,
'e1rm_scale': [_format_kg(round(max_e1rm, 1)), _format_kg(round((max_e1rm + min_e1rm) / 2, 1)), _format_kg(round(min_e1rm, 1))],
'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,
'exercise_id': exercise_id,
'min_date': min_date,
'max_date': max_date,
'degree': degree
}
'min_date': observed_min_date,
'max_date': observed_max_date,
'degree': degree,
'forecast': forecast,
'forecast_enabled': forecast_enabled,
'forecast_weeks': forecast_weeks,
'forecast_target_reps': forecast_target_reps,
'forecast_horizons': forecast_horizons,
'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,
'hover_details': hover_details,
'default_detail': forecast_details[0] if forecast_details else plot_details[-1],
}
def get_distinct_colors(n):
colors = []
@@ -117,11 +333,11 @@ def get_distinct_colors(n):
def calculate_estimated_1rm(weight, repetitions):
def calculate_estimated_1rm(weight, repetitions):
# Ensure the inputs are numeric
if repetitions == 0: # Avoid division by zero
return 0
estimated_1rm = round((100 * int(weight)) / (101.3 - 2.67123 * repetitions), 0)
estimated_1rm = round((100 * int(weight)) / (E1RM_BASE - E1RM_REP_FACTOR * repetitions), 0)
return int(estimated_1rm)