Compare commits
15
Commits
ff6a921550
...
0c0bcdc4b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c0bcdc4b6 | ||
|
|
9612d322eb | ||
|
|
dc0f41cd27 | ||
|
|
d5e3e1d930 | ||
|
|
1aa5d7418b | ||
|
|
86fade54f1 | ||
|
|
499d859478 | ||
|
|
50ddbced84 | ||
|
|
0dea94fec0 | ||
|
|
d26da9a066 | ||
|
|
7af0202c30 | ||
|
|
499ecdc3c2 | ||
|
|
cada28b54b | ||
|
|
e2c4196792 | ||
|
|
d1100ece80 |
+2
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }} × {{ 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 × 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 %}
|
||||
@@ -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()
|
||||
@@ -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 <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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user