feat Show exact progressive-overload evidence

A Heavy Duty logbook must answer whether the trainee was stronger than last time, and exactly how. Add Progress.workout_comparisons: each distinct exercise with a prior baseline, carrying exact prior/current efforts in performance order. workout_changes filters it through the same overload rule used by workout_progress, so the web never recreates "beats" or the strict-before-start correction guard. Rows now scan date, workout, duration, and a headline such as 3/5 progressed. First performances are excluded from the denominator. A ?expand=record URL opens one full-width detail row, one at a time, with exact prior/current load × reps plus compact deltas for positive changes. No numeric progress score or volume target is introduced.

Commit
73d6e9b3c855a1a585377cb8b070e01a8e09ac73
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index 140493a9..dcb00dd1 100644..100644
@@ -507,6 +507,17 @@
507 507 This is native disclosure and route state, not an ARIA widget —
508 508 deterministic, and correct with no client script at all.
509 509
510 Added: ** The logbook
511 Added:
512 Added: Each saved workout is a compact ledger row: date, routine day, finished
513 Added: duration, and a progressive-overload headline such as =3/5 progressed=.
514 Added: The denominator is only exercises with a prior baseline; first performances do
515 Added: not inflate it. When a workout has positive changes, its headline is a
516 Added: =?expand=:id= link. It opens one full-width detail row beneath the workout,
517 Added: showing the exact prior and current load × reps and their deltas for each
518 Added: progressed exercise. =Progress= supplies the comparisons and is the only place
519 Added: that judges what counts as overload; the web tier only phrases the evidence.
520 Added:
510 521 ** Progressive enhancement
511 522
512 523 The =js_of_ocaml= client is an enhancement, never a
lib/core/progress.ml
index 24922570..a23f3e3d 100644..100644
@@ -164,40 +164,51 @@
164 164 | Trained_under_recovered n ->
165 165 Format.fprintf ppf "%d workout(s) begun before recovery was complete" n
166 166
167 Removed: (* How many distinct exercises in [workout] beat their most recent earlier
168 Removed: observation in [log]. Progress is the Heavy Duty signal — an increase in reps
169 Removed: or load — judged here, in the one module that judges, so a caller receives a
170 Removed: count and never re-derives the rule. An exercise with no earlier observation
171 Removed: does not count: a first performance has nothing to beat. The comparison
172 Removed: baseline is the latest observation strictly before this workout started, so
173 Removed: re-opening a saved workout to correct it does not compare an exercise against
174 Removed: itself. *)
175 Removed: let workout_progress log workout =
167 Added: type workout_comparison = {
168 Added: exercise : Exercise.t;
169 Added: previous : Evidence.Stimulus.Effort.t;
170 Added: current : Evidence.Stimulus.Effort.t;
171 Added: }
172 Added:
173 Added: (* The latest observation strictly before the workout started. This prevents a
174 Added: correction of the saved workout from comparing an exercise against itself. *)
175 Added: let previous_effort log ~started exercise =
176 Added: Evidence.Log.observations log exercise
177 Added: |> List.filter (fun observation ->
178 Added: Recovery.duration_to_seconds
179 Added: (Recovery.elapsed
180 Added: ~since:(Evidence.Observation.performed_at observation)
181 Added: ~now:started)
182 Added: > 0)
183 Added: |> last
184 Added: |> Option.map Evidence.Observation.effort
185 Added:
186 Added: let workout_comparisons log workout =
176 187 let started = Evidence.Workout.started_at workout in
177 Removed: let progressed exercise current =
178 Removed: let prior =
179 Removed: Evidence.Log.observations log exercise
180 Removed: |> List.filter (fun o ->
181 Removed: Recovery.duration_to_seconds
182 Removed: (Recovery.elapsed
183 Removed: ~since:(Evidence.Observation.performed_at o)
184 Removed: ~now:started)
185 Removed: > 0)
186 Removed: in
187 Removed: match last prior with
188 Removed: | None -> false
189 Removed: | Some previous ->
190 Removed: beats ~previous:(Evidence.Observation.effort previous) ~current
191 Removed: in
192 188 Evidence.Workout.stimuli workout
193 189 |> List.concat_map Evidence.Stimulus.efforts
194 190 |> List.fold_left
195 Removed: (fun (seen, count) effort ->
196 Removed: let exercise = Evidence.Stimulus.Effort.exercise effort in
197 Removed: if
198 Removed: List.exists (Exercise.equal exercise) seen
199 Removed: || not (progressed exercise effort)
200 Removed: then (exercise :: seen, count)
201 Removed: else (exercise :: seen, count + 1))
202 Removed: ([], 0)
203 Removed: |> snd
191 Added: (fun (seen, comparisons) current ->
192 Added: let exercise = Evidence.Stimulus.Effort.exercise current in
193 Added: if List.exists (Exercise.equal exercise) seen then (seen, comparisons)
194 Added: else
195 Added: let comparison =
196 Added: Option.map
197 Added: (fun previous -> { exercise; previous; current })
198 Added: (previous_effort log ~started exercise)
199 Added: in
200 Added: (exercise :: seen, Option.to_list comparison @ comparisons))
201 Added: ([], [])
202 Added: |> snd |> List.rev
203 Added:
204 Added: let workout_changes log workout =
205 Added: workout_comparisons log workout
206 Added: |> List.filter (fun comparison ->
207 Added: beats ~previous:comparison.previous ~current:comparison.current)
208 Added:
209 Added: (* How many distinct exercises in [workout] beat their most recent earlier
210 Added: observation in [log]. Progress is the Heavy Duty signal — an increase in reps
211 Added: or load — judged here, in the one module that judges, so a caller receives a
212 Added: count and never re-derives the rule. An exercise with no earlier observation
213 Added: does not count. *)
214 Added: let workout_progress log workout = List.length (workout_changes log workout)
lib/core/progress.mli
index b4f33f35..8373650a 100644..100644
@@ -54,6 +54,24 @@
54 54
55 55 val pp_load_verdict : Format.formatter -> load_verdict -> unit
56 56
57 Added: type workout_comparison = {
58 Added: exercise : Exercise.t;
59 Added: previous : Evidence.Stimulus.Effort.t;
60 Added: current : Evidence.Stimulus.Effort.t;
61 Added: }
62 Added: (** One exercise in a workout with a most-recent earlier observation. The
63 Added: baseline is strictly before the workout started, so correcting a saved
64 Added: workout never compares an exercise against itself. *)
65 Added:
66 Added: val workout_comparisons :
67 Added: Evidence.Log.t -> Evidence.Workout.t -> workout_comparison list
68 Added: (** Distinct exercises with a prior baseline, in workout performance order. *)
69 Added:
70 Added: val workout_changes :
71 Added: Evidence.Log.t -> Evidence.Workout.t -> workout_comparison list
72 Added: (** The comparisons that show progressive overload: more load, or equal load and
73 Added: more reps. One extra rep counts. *)
74 Added:
57 75 (** Counts recorded workouts that show each overtraining risk. *)
58 76 type diagnostic =
59 77 | Excess_volume of int
lib/presenter/pages.mli
index db20aa10..2ca1fcba 100644..100644
@@ -61,6 +61,7 @@
61 61 ?suggest_feedback:bool ->
62 62 ?feedback_open:bool ->
63 63 ?delete_record:View_model.Logbook_entry.t ->
64 Added: ?expanded_record:View_model.Logbook_entry.t ->
64 65 View_model.Logbook_entry.t list ->
65 66 page
66 67 (** The logbook: recorded workouts and a modal subjective feedback form that
lib/presenter/pages_logbook.ml
index 27eb2d10..27863f6d 100644..100644
@@ -137,9 +137,60 @@
137 137 ];
138 138 ]
139 139
140 Added: let signed_number n = if n > 0 then Printf.sprintf "+%d" n else string_of_int n
141 Added:
142 Added: let change_delta (change : View_model.Logbook_change.t) =
143 Added: let load = change.current_load -. change.previous_load in
144 Added: let reps = change.current_reps - change.previous_reps in
145 Added: let pieces =
146 Added: (if Float.equal load 0. then [] else [ Printf.sprintf "%+g kg" load ])
147 Added: @
148 Added: if reps = 0 then []
149 Added: else
150 Added: [
151 Added: Printf.sprintf "%s rep%s" (signed_number reps)
152 Added: (if abs reps = 1 then "" else "s");
153 Added: ]
154 Added: in
155 Added: String.concat " · " pieces
156 Added:
157 Added: let progress_detail_row (entry : View_model.Logbook_entry.t) =
158 Added: tag "tr"
159 Added: [ class_ "logbook-detail-row" ]
160 Added: [
161 Added: tag "td"
162 Added: [ Dream_html.string_attr "colspan" "5" ]
163 Added: [
164 Added: tag "section"
165 Added: [ class_ "logbook-progress-detail" ]
166 Added: [
167 Added: tag "h2" [] [ txt "Progress since the prior workout" ];
168 Added: tag "ul"
169 Added: [ class_ "logbook-change-list" ]
170 Added: (List.map
171 Added: (fun (change : View_model.Logbook_change.t) ->
172 Added: tag "li" []
173 Added: [
174 Added: tag "strong" [] [ txt "%s" change.exercise_name ];
175 Added: tag "span"
176 Added: [ class_ "change-efforts" ]
177 Added: [
178 Added: txt "%g kg × %d → %g kg × %d" change.previous_load
179 Added: change.previous_reps change.current_load
180 Added: change.current_reps;
181 Added: ];
182 Added: tag "span"
183 Added: [ class_ "change-delta" ]
184 Added: [ txt "%s" (change_delta change) ];
185 Added: ])
186 Added: entry.changes);
187 Added: ];
188 Added: ];
189 Added: ]
190 Added:
140 191 let logbook request ?(logging = false) ~viewer ?(feedback = [])
141 Removed: ?(suggest_feedback = false) ?(feedback_open = false) ?delete_record records
142 Removed: =
192 Added: ?(suggest_feedback = false) ?(feedback_open = false) ?delete_record
193 Added: ?expanded_record records =
143 194 html_page ~viewer ~request ~active:"logbook" ~logging "Logbook"
144 195 [
145 196 tag "h1" [] [ txt "Logbook" ];
@@ -160,63 +211,112 @@
160 211 [ txt "Workout" ];
161 212 tag "th"
162 213 [ Dream_html.string_attr "scope" "col" ]
163 Removed: [ txt "Exercises progressed" ];
214 Added: [ txt "Duration" ];
164 215 tag "th"
216 Added: [ Dream_html.string_attr "scope" "col" ]
217 Added: [ txt "Progress" ];
218 Added: tag "th"
165 219 ([ class_ "logbook-actions-heading" ]
166 220 @ [ Dream_html.string_attr "scope" "col" ])
167 221 [ tag "span" [ class_ "sr-only" ] [ txt "Actions" ] ];
168 222 ];
169 223 ];
170 224 tag "tbody" []
171 Removed: (List.map
225 Added: (List.concat_map
172 226 (fun (entry : View_model.Logbook_entry.t) ->
173 Removed: tag "tr" []
174 Removed: [
175 Removed: tag "td" [] [ txt "%s" entry.date ];
176 Removed: tag "td" [] [ txt "%s" entry.workout_name ];
177 Removed: tag "td"
178 Removed: [ class_ "logbook-progress" ]
179 Removed: [ txt "%d" entry.progressed_exercises ];
180 Removed: tag "td"
181 Removed: [ class_ "logbook-actions" ]
227 Added: let expanded =
228 Added: Option.exists
229 Added: (fun record ->
230 Added: String.equal record.View_model.Logbook_entry.id
231 Added: entry.id)
232 Added: expanded_record
233 Added: in
234 Added: let progress =
235 Added: if entry.compared_exercises = 0 then
236 Added: tag "span"
237 Added: [ class_ "logbook-no-progress" ]
238 Added: [ txt "No prior comparison" ]
239 Added: else if entry.changes = [] then
240 Added: tag "span"
241 Added: [ class_ "logbook-no-progress" ]
242 Added: [ txt "No progress" ]
243 Added: else
244 Added: let progress_attrs =
182 245 [
183 Removed: tag "details"
184 Removed: [ class_ "logbook-row-menu" ]
185 Removed: [
186 Removed: tag "summary"
187 Removed: [
188 Removed: Dream_html.string_attr "aria-label"
189 Removed: "Actions for %s on %s" entry.workout_name
190 Removed: entry.date;
191 Removed: ]
192 Removed: [ txt "⋮" ];
193 Removed: tag "div"
194 Removed: [
195 Removed: class_ "logbook-row-menu-drawer";
196 Removed: Dream_html.string_attr "role" "menu";
197 Removed: Dream_html.string_attr "aria-label"
198 Removed: "Workout actions";
199 Removed: ]
200 Removed: [
201 Removed: tag "a"
202 Removed: [
203 Removed: href Routes.record entry.id;
204 Removed: Dream_html.attr "data-hito-app-link";
205 Removed: Dream_html.string_attr "role" "menuitem";
206 Removed: ]
207 Removed: [ txt "Edit" ];
208 Removed: tag "a"
209 Removed: [
210 Removed: Dream_html.string_attr "href"
211 Removed: "/logbook?delete=%s" entry.id;
212 Removed: Dream_html.string_attr "role" "menuitem";
213 Removed: class_ "danger";
214 Removed: ]
215 Removed: [ txt "Delete" ];
216 Removed: ];
217 Removed: ];
218 Removed: ];
219 Removed: ])
246 Added: class_ "logbook-progress-toggle";
247 Added: Dream_html.string_attr "aria-expanded" "%b" expanded;
248 Added: ]
249 Added: @
250 Added: if expanded then [ href Routes.logbook ]
251 Added: else
252 Added: [
253 Added: Dream_html.string_attr "href" "/logbook?expand=%s"
254 Added: entry.id;
255 Added: ]
256 Added: in
257 Added: tag "a" progress_attrs
258 Added: [
259 Added: txt "%d/%d progressed %s" entry.progressed_exercises
260 Added: entry.compared_exercises
261 Added: (if expanded then "▴" else "▾");
262 Added: ]
263 Added: in
264 Added: let row =
265 Added: tag "tr"
266 Added: (if expanded then [ class_ "logbook-row-expanded" ]
267 Added: else [])
268 Added: [
269 Added: tag "td" [] [ txt "%s" entry.date ];
270 Added: tag "td" [] [ txt "%s" entry.workout_name ];
271 Added: tag "td"
272 Added: [ class_ "logbook-duration" ]
273 Added: [ txt "%s" entry.duration ];
274 Added: tag "td" [ class_ "logbook-progress" ] [ progress ];
275 Added: tag "td"
276 Added: [ class_ "logbook-actions" ]
277 Added: [
278 Added: tag "details"
279 Added: [ class_ "logbook-row-menu" ]
280 Added: [
281 Added: tag "summary"
282 Added: [
283 Added: Dream_html.string_attr "aria-label"
284 Added: "Actions for %s on %s"
285 Added: entry.workout_name entry.date;
286 Added: ]
287 Added: [ txt "⋮" ];
288 Added: tag "div"
289 Added: [
290 Added: class_ "logbook-row-menu-drawer";
291 Added: Dream_html.string_attr "role" "menu";
292 Added: Dream_html.string_attr "aria-label"
293 Added: "Workout actions";
294 Added: ]
295 Added: [
296 Added: tag "a"
297 Added: [
298 Added: href Routes.record entry.id;
299 Added: Dream_html.attr "data-hito-app-link";
300 Added: Dream_html.string_attr "role"
301 Added: "menuitem";
302 Added: ]
303 Added: [ txt "Edit" ];
304 Added: tag "a"
305 Added: [
306 Added: Dream_html.string_attr "href"
307 Added: "/logbook?delete=%s" entry.id;
308 Added: Dream_html.string_attr "role"
309 Added: "menuitem";
310 Added: class_ "danger";
311 Added: ]
312 Added: [ txt "Delete" ];
313 Added: ];
314 Added: ];
315 Added: ];
316 Added: ]
317 Added: in
318 Added: if expanded then [ row; progress_detail_row entry ]
319 Added: else [ row ])
220 320 records);
221 321 ]);
222 322 tag "section"
lib/presenter/view_model.ml
index 105ddde6..6120fe88 100644..100644
@@ -118,18 +118,34 @@
118 118 form, since its option set is fixed UI vocabulary, not domain data. *)
119 119 end
120 120
121 Added: module Logbook_change = struct
122 Added: type t = {
123 Added: exercise_name : string;
124 Added: previous_load : float;
125 Added: previous_reps : int;
126 Added: current_load : float;
127 Added: current_reps : int;
128 Added: }
129 Added: (** Exact evidence for one progressed exercise. The presenter derives display
130 Added: deltas from these plain values, never from a core effort. *)
131 Added: end
132 Added:
121 133 module Logbook_entry = struct
122 134 type t = {
123 Removed: id : string; (** Record id for the link. *)
135 Added: id : string; (** Record id for links and URL-addressable expansion. *)
124 136 workout_name : string;
125 137 date : string; (** Workout date, pre-formatted "YYYY-MM-DD" in UTC. *)
138 Added: duration : string;
139 Added: (** Finished elapsed time, pre-formatted for scanning. *)
126 140 progressed_exercises : int;
127 Removed: (** How many exercises beat their previous record, judged by
128 Removed: {!Progress}. *)
141 Added: compared_exercises : int;
142 Added: (** Exercises with a prior baseline. First performances do not count. *)
143 Added: changes : Logbook_change.t list;
144 Added: (** Exact positive overload changes, in workout performance order. *)
129 145 }
130 Removed: (** One saved workout as the logbook table shows it: a link, a name, the date,
131 Removed: and how many exercises progressed. No Repository record and no
132 Removed: Evidence.Workout entity. *)
146 Added: (** One saved workout as the logbook table shows it: compact evidence for
147 Added: scanning, plus the proof needed by the expanded detail row. No Repository
148 Added: record and no Evidence.Workout entity. *)
133 149 end
134 150
135 151 module Import_warning = struct
lib/web/assets/hito.css
index c52f7f37..8b263d4e 100644..100644
@@ -1377,3 +1377,71 @@
1377 1377 .confirm-dialog button.danger:hover { background: var(--oxblood); }
1378 1378 .confirm-dialog .delete-record-form { flex: 1 1 10rem; margin: 0; }
1379 1379 .confirm-dialog .delete-record-form button { width: 100%; }
1380 Added:
1381 Added: /* Compact Heavy Duty evidence in the logbook. The headline scans duration and
1382 Added: progressive overload; the full-width row beneath it supplies the proof. */
1383 Added: .logbook-duration,
1384 Added: .logbook-progress {
1385 Added: white-space: nowrap;
1386 Added: font-family: var(--mono);
1387 Added: }
1388 Added: .logbook-progress { text-align: right; }
1389 Added: .logbook-progress-toggle {
1390 Added: display: inline-flex;
1391 Added: align-items: baseline;
1392 Added: gap: 0.25rem;
1393 Added: color: var(--oxblood-dark);
1394 Added: font: inherit;
1395 Added: font-weight: 700;
1396 Added: text-decoration: none;
1397 Added: }
1398 Added: .logbook-progress-toggle:hover { text-decoration: underline; }
1399 Added: .logbook-no-progress { color: var(--muted-ink); }
1400 Added: .logbook-row-expanded > td { border-bottom: none; }
1401 Added: .logbook-detail-row > td {
1402 Added: padding: 0;
1403 Added: border-bottom: 1px solid var(--rule);
1404 Added: }
1405 Added: .logbook-progress-detail {
1406 Added: padding: 0.85rem 1rem 1rem;
1407 Added: background: var(--brass-wash);
1408 Added: animation: logbook-detail-open 180ms ease both;
1409 Added: }
1410 Added: .logbook-progress-detail h2 {
1411 Added: margin: 0 0 0.6rem;
1412 Added: font-size: var(--font-size-small);
1413 Added: }
1414 Added: .logbook-change-list {
1415 Added: display: grid;
1416 Added: gap: 0.4rem;
1417 Added: margin: 0;
1418 Added: padding: 0;
1419 Added: list-style: none;
1420 Added: }
1421 Added: .logbook-change-list li {
1422 Added: display: grid;
1423 Added: grid-template-columns: minmax(0, 1fr) auto auto;
1424 Added: gap: 0.5rem;
1425 Added: align-items: baseline;
1426 Added: }
1427 Added: .change-efforts { font-family: var(--mono); white-space: nowrap; }
1428 Added: .change-delta {
1429 Added: color: var(--oxblood-dark);
1430 Added: font: 700 var(--font-size-small)/1 var(--sans);
1431 Added: white-space: nowrap;
1432 Added: }
1433 Added: @keyframes logbook-detail-open {
1434 Added: from { opacity: 0; transform: translateY(-0.25rem); }
1435 Added: to { opacity: 1; transform: none; }
1436 Added: }
1437 Added: @media (max-width: 34rem) {
1438 Added: .logbook-table th,
1439 Added: .logbook-table td { padding: 0.45rem; }
1440 Added: .logbook-change-list li {
1441 Added: grid-template-columns: 1fr auto;
1442 Added: }
1443 Added: .change-efforts { grid-column: 1 / -1; }
1444 Added: }
1445 Added: @media (prefers-reduced-motion: reduce) {
1446 Added: .logbook-progress-detail { animation: none; }
1447 Added: }
lib/web/handlers_logbook.ml
index fa2f8738..526f2326 100644..100644
@@ -40,6 +40,29 @@
40 40 (Recovery.timestamp_to_unix_seconds
41 41 (Evidence.Workout.started_at workout)))
42 42 in
43 Added: let comparisons = Progress.workout_comparisons log workout in
44 Added: let changes =
45 Added: Progress.workout_changes log workout
46 Added: |> List.map (fun comparison ->
47 Added: View_model.Logbook_change.
48 Added: {
49 Added: exercise_name = Exercise.name comparison.Progress.exercise;
50 Added: previous_load =
51 Added: Evidence.Stimulus.Effort.load comparison.Progress.previous;
52 Added: previous_reps =
53 Added: Evidence.Stimulus.Effort.reps comparison.Progress.previous;
54 Added: current_load =
55 Added: Evidence.Stimulus.Effort.load comparison.Progress.current;
56 Added: current_reps =
57 Added: Evidence.Stimulus.Effort.reps comparison.Progress.current;
58 Added: })
59 Added: in
60 Added: let duration =
61 Added: match Evidence.Workout.duration workout with
62 Added: | None -> "—"
63 Added: | Some duration ->
64 Added: Printf.sprintf "%d min" (Recovery.duration_to_seconds duration / 60)
65 Added: in
43 66 {
44 67 id = (record.Repository.id :> string);
45 68 workout_name =
@@ -47,7 +70,10 @@
47 70 date =
48 71 Printf.sprintf "%04d-%02d-%02d" (tm.Unix.tm_year + 1900)
49 72 (tm.Unix.tm_mon + 1) tm.Unix.tm_mday;
50 Removed: progressed_exercises = Progress.workout_progress log workout;
73 Added: duration;
74 Added: progressed_exercises = List.length changes;
75 Added: compared_exercises = List.length comparisons;
76 Added: changes;
51 77 }
52 78
53 79 let feedback_view (report : Feedback.t) : View_model.Feedback.report =
@@ -77,6 +103,14 @@
77 103 records
78 104 | None -> None
79 105 in
106 Added: let expanded_record =
107 Added: match Dream.query request "expand" with
108 Added: | Some id ->
109 Added: List.find_opt
110 Added: (fun entry -> String.equal entry.View_model.Logbook_entry.id id)
111 Added: records
112 Added: | None -> None
113 Added: in
80 114 let feedback_open =
81 115 match (Dream.query request "feedback", Dream.query request "prompt") with
82 116 | Some "start", _ | _, Some "feedback" -> true
@@ -90,7 +124,7 @@
90 124 (match Dream.query request "prompt" with
91 125 | Some "feedback" -> true
92 126 | _ -> false)
93 Removed: ~feedback_open ?delete_record records)
127 Added: ~feedback_open ?delete_record ?expanded_record records)
94 128
95 129 (* One post saves the whole report. Nothing is stored before Submit, so there
96 130 is no partial state to keep or discard. An empty submission is a mistake
test/test_progression.ml
index 4e174506..c8306aad 100644..100644
@@ -348,6 +348,59 @@
348 348 (Progress.workout_progress (log_of [ first; second ]) second) );
349 349 ]
350 350
351 Added: let workout_change_tests =
352 Added: [
353 Added: ( "a first workout has no comparison or change",
354 Added: `Quick,
355 Added: fun () ->
356 Added: let w = solo ~on:1 ~load:12. ~r:8 in
357 Added: Alcotest.(check int)
358 Added: "no comparisons" 0
359 Added: (List.length (Progress.workout_comparisons (log_of [ w ]) w));
360 Added: Alcotest.(check int)
361 Added: "no changes" 0
362 Added: (List.length (Progress.workout_changes (log_of [ w ]) w)) );
363 Added: ( "a rep increase retains exact prior and current efforts",
364 Added: `Quick,
365 Added: fun () ->
366 Added: let first = solo ~on:1 ~load:12. ~r:8 in
367 Added: let second = solo ~on:8 ~load:12. ~r:9 in
368 Added: match Progress.workout_changes (log_of [ first; second ]) second with
369 Added: | [ comparison ] ->
370 Added: Alcotest.(check (float 0.001))
371 Added: "prior load" 12.
372 Added: (Stimulus.Effort.load comparison.previous);
373 Added: Alcotest.(check int)
374 Added: "prior reps" 8
375 Added: (Stimulus.Effort.reps comparison.previous);
376 Added: Alcotest.(check int)
377 Added: "current reps" 9
378 Added: (Stimulus.Effort.reps comparison.current)
379 Added: | _ -> Alcotest.fail "expected one rep change" );
380 Added: ( "a load increase is a change even with fewer reps",
381 Added: `Quick,
382 Added: fun () ->
383 Added: let first = solo ~on:1 ~load:12. ~r:8 in
384 Added: let second = solo ~on:8 ~load:14. ~r:7 in
385 Added: Alcotest.(check int)
386 Added: "one load change" 1
387 Added: (List.length
388 Added: (Progress.workout_changes (log_of [ first; second ]) second)) );
389 Added: ( "load and reps increasing are one exercise change",
390 Added: `Quick,
391 Added: fun () ->
392 Added: let first = solo ~on:1 ~load:12. ~r:8 in
393 Added: let second = solo ~on:8 ~load:14. ~r:9 in
394 Added: Alcotest.(check int)
395 Added: "one exact comparison" 1
396 Added: (List.length
397 Added: (Progress.workout_comparisons (log_of [ first; second ]) second));
398 Added: Alcotest.(check int)
399 Added: "one change" 1
400 Added: (List.length
401 Added: (Progress.workout_changes (log_of [ first; second ]) second)) );
402 Added: ]
403 Added:
351 404 let suite =
352 405 [
353 406 ("progression.assess", assess_tests);
@@ -355,4 +408,5 @@
355 408 ("progression.load", load_tests);
356 409 ("progression.diagnostics", diagnostic_tests);
357 410 ("progression.workout_progress", workout_progress_tests);
411 Added: ("progression.workout_changes", workout_change_tests);
358 412 ]
test/web/logbook.ml
index a0011024..bba2ecef 100644..100644
@@ -40,10 +40,77 @@
40 40 "the logbook table is present" true
41 41 (Option.is_some
42 42 (index_from ~needle:"class=\"logbook-table\"" page 0));
43 Removed: Alcotest.(check bool)
44 Removed: "the progressed-exercises column header" true
45 Removed: (Option.is_some
46 Removed: (index_from ~needle:"Exercises progressed" page 0)) );
43 Added: List.iter
44 Added: (fun heading ->
45 Added: Alcotest.(check bool)
46 Added: ("the " ^ heading ^ " column header")
47 Added: true
48 Added: (Option.is_some (index_from ~needle:heading page 0)))
49 Added: [ "Duration"; "Progress" ] );
50 Added: ( "an expanded row shows exact Heavy Duty progress evidence",
51 Added: `Quick,
52 Added: fun () ->
53 Added: let c, repo = client_with_repo () in
54 Added: let _ = register c ~username:"lifter" ~password:"heavyduty1" in
55 Added: let trainee = the_trainee repo ~username:"lifter" in
56 Added: let day_one =
57 Added: List.hd (Prescription.Routine.workouts Routines.ideal)
58 Added: in
59 Added: let laterals =
60 Added: Option.get
61 Added: (Exercise.find
62 Added: {
63 Added: equipment = Unspecified;
64 Added: movement = Lateral_raise;
65 Added: variation = Standard;
66 Added: })
67 Added: in
68 Added: let saved ~started ~load ~reps =
69 Added: let workout =
70 Added: Evidence.Workout.start day_one
71 Added: ~clearance:(Option.get (Recovery.clear Recovery.Ready))
72 Added: ~started_at:(Recovery.timestamp_of_unix_seconds started)
73 Added: in
74 Added: let effort =
75 Added: Evidence.Stimulus.Effort.make ~exercise:laterals ~load ~reps
76 Added: ~outcome:Evidence.Stimulus.Positive_failure
77 Added: in
78 Added: let workout =
79 Added: Evidence.Workout.add_stimulus workout
80 Added: (Evidence.Stimulus.make (Evidence.Stimulus.Single effort))
81 Added: in
82 Added: Evidence.Workout.finish workout
83 Added: ~ended_at:(Recovery.timestamp_of_unix_seconds (started + 1080))
84 Added: in
85 Added: ignore
86 Added: (Lwt_main.run
87 Added: (Memory_repo.finish_workout repo trainee.Trainee.id
88 Added: (saved ~started:0 ~load:10. ~reps:8)));
89 Added: ignore
90 Added: (Lwt_main.run
91 Added: (Memory_repo.finish_workout repo trainee.Trainee.id
92 Added: (saved ~started:172_800 ~load:12. ~reps:9)));
93 Added: let compact = body (get c "/logbook") in
94 Added: List.iter
95 Added: (fun needle ->
96 Added: Alcotest.(check bool)
97 Added: ("compact row shows " ^ needle)
98 Added: true
99 Added: (Option.is_some (index_from ~needle compact 0)))
100 Added: [ "18 min"; "1/1 progressed"; "/logbook?expand=w2" ];
101 Added: let expanded = body (get c "/logbook?expand=w2") in
102 Added: List.iter
103 Added: (fun needle ->
104 Added: Alcotest.(check bool)
105 Added: ("expanded row shows " ^ needle)
106 Added: true
107 Added: (Option.is_some (index_from ~needle expanded 0)))
108 Added: [
109 Added: "Progress since the prior workout";
110 Added: "Laterals";
111 Added: "10 kg × 8 → 12 kg × 9";
112 Added: "+2 kg · +1 rep";
113 Added: ] );
47 114 ( "each saved workout row has an edit and confirmation-gated delete menu",
48 115 `Quick,
49 116 fun () ->