feat show each next exercise's best weight and reps on Home

Previously the home preview listed only the upcoming workout's exercise names. Now each line shows the exercise's current best weight × rep combination beside it, the record to beat, following the Heavy Duty progress chart (HD1 rule 11: record the weight and reps for each exercise; even a one-rep increase is significant). The best is the most recent effort — the set you must beat next — drawn from the merged native and imported history, so an imported effort also sets the bar. It is shown as an observed fact, not a target: the set still ends at failure, and the rep band only calibrates load. A pre-exhaust pair shows a line per underlying exercise.

Commit
1fb6d44fc96b1f8a60cf01c8c272038a826e525a
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/pages_routine.ml
index 3a480885..112ba4e4 100644..100644
@@ -144,7 +144,20 @@
144 144 tag "ul"
145 145 [ class_ "next-workout-preview" ]
146 146 (List.map
147 Removed: (fun name -> tag "li" [] [ txt "%s" name ])
147 Added: (fun (line : View_model.Home.exercise_line) ->
148 Added: tag "li" []
149 Added: (tag "span"
150 Added: [ class_ "next-workout-exercise" ]
151 Added: [ txt "%s" line.name ]
152 Added: ::
153 Added: (match line.best with
154 Added: | Some best ->
155 Added: [
156 Added: tag "span"
157 Added: [ class_ "next-workout-best" ]
158 Added: [ txt "%s" best ];
159 Added: ]
160 Added: | None -> [])))
148 161 vm.next_exercises));
149 162 tag "p" [] [ txt "%s" status ];
150 163 ]
lib/presenter/view_model.ml
index 1c5cb4fc..d48d6ebb 100644..100644
@@ -63,13 +63,25 @@
63 63 (** Whether the next workout may begin, and the recovery status line to
64 64 show while it may not. *)
65 65
66 Added: type exercise_line = {
67 Added: name : string; (** The prescribed exercise, phrased. *)
68 Added: best : string option;
69 Added: (** The exercise's current best weight × rep combination, the record to
70 Added: beat, pre-formatted (for example ["60 kg × 12 reps"]). [None] when
71 Added: the exercise has no earlier observation. An observed fact, not a
72 Added: target — the set still ends at failure. *)
73 Added: }
74 Added: (** One line of the next-workout preview: an exercise and the weight × reps to
75 Added: beat, per the Heavy Duty progress chart. *)
76 Added:
66 77 type t = {
67 78 routine_id : string; (** For the begin/override forms. *)
68 79 routine_name : string;
69 80 next_workout : string;
70 Removed: next_exercises : string list;
71 Removed: (** The prescribed exercises of the upcoming workout, phrased, in order.
72 Removed: Shown on the card to preview what is coming. *)
81 Added: next_exercises : exercise_line list;
82 Added: (** The prescribed exercises of the upcoming workout, in order, each
83 Added: with its current best weight × reps. Shown on the card to preview
84 Added: what is coming and the record to beat. *)
73 85 gate : gate;
74 86 }
75 87 (** The overview while no workout is in progress. The recovery policy has
lib/web/assets/hito.css
index 92920cb0..a245469d 100644..100644
@@ -375,7 +375,21 @@
375 375 padding-left: 1.1rem;
376 376 color: var(--ink);
377 377 }
378 Removed: .next-workout-preview li { padding: 0.2rem 0; }
378 Added: .next-workout-preview li {
379 Added: display: flex;
380 Added: align-items: baseline;
381 Added: gap: 0.6rem;
382 Added: padding: 0.2rem 0;
383 Added: }
384 Added: .next-workout-exercise { flex: 1 1 auto; }
385 Added: /* The current best weight × reps to beat: an observed fact, monospaced and
386 Added: quiet, aligned to the right of each exercise. */
387 Added: .next-workout-best {
388 Added: flex: 0 0 auto;
389 Added: font-family: var(--mono);
390 Added: color: var(--muted-ink);
391 Added: white-space: nowrap;
392 Added: }
379 393
380 394 fieldset {
381 395 margin: 1rem 0 0;
lib/web/handlers_overview.ml
index 23d1b9f3..d698672f 100644..100644
@@ -45,14 +45,51 @@
45 45 (Prescription.Routine.workouts r);
46 46 }
47 47
48 Added: (* The most recent effort for [observations], pre-formatted as the record to
49 Added: beat. History is oldest first, so the best current combination — the one to
50 Added: beat next — is the last. An observed fact, not a target. *)
51 Added: let best_of observations =
52 Added: match List.rev observations with
53 Added: | latest :: _ ->
54 Added: let e = Evidence.Observation.effort latest in
55 Added: Some
56 Added: (Printf.sprintf "%g kg × %d reps"
57 Added: (Evidence.Stimulus.Effort.load e)
58 Added: (Evidence.Stimulus.Effort.reps e))
59 Added: | [] -> None
60 Added:
61 Added: (* The exercises of one prescribed stimulus, in order. A pre-exhaust pair
62 Added: yields two, one per underlying exercise, so every exercise shows its own
63 Added: weight × reps to beat. *)
64 Added: let stimulus_exercises prescription =
65 Added: match Prescription.Stimulus.delivery prescription with
66 Added: | Prescription.Stimulus.Single exercise -> [ exercise ]
67 Added: | Prescription.Stimulus.Pre_exhaust { isolation; compound } ->
68 Added: [ isolation; compound ]
69 Added:
70 Added: (* Build the preview lines for the next workout, each with its current best
71 Added: from the merged native and imported history. *)
72 Added: let next_exercise_lines t trainee next :
73 Added: View_model.Home.exercise_line list Lwt.t =
74 Added: let exercises =
75 Added: List.concat_map stimulus_exercises (Prescription.Workout.stimuli next)
76 Added: in
77 Added: Lwt_list.map_s
78 Added: (fun exercise ->
79 Added: Service.exercise_history t.service trainee exercise >|= fun history ->
80 Added: {
81 Added: View_model.Home.name = Exercise.name exercise;
82 Added: best = best_of history;
83 Added: })
84 Added: exercises
85 Added:
48 86 let home_view ~(routine : Repository.routine_id) ~routine_name ~next
49 Removed: ~readiness : View_model.Home.t =
87 Added: ~readiness ~next_exercises : View_model.Home.t =
50 88 {
51 89 routine_id = (routine :> string);
52 90 routine_name;
53 91 next_workout = Prescription.Workout.name next;
54 Removed: next_exercises =
55 Removed: List.map exercise_string (Prescription.Workout.stimuli next);
92 Added: next_exercises;
56 93 gate =
57 94 (match readiness with
58 95 | Recovery.Ready -> View_model.Home.Ready
@@ -92,17 +129,20 @@
92 129 >>= fun next ->
93 130 Service.readiness t.service trainee.Trainee.id ~routine
94 131 ~now:(t.now ())
95 Removed: >|= fun readiness ->
132 Added: >>= fun readiness ->
96 133 match (next, readiness) with
97 134 | Ok next, Ok readiness ->
135 Added: next_exercise_lines t trainee.Trainee.id next
136 Added: >|= fun next_exercises ->
98 137 Pages.home request ~viewer
99 138 ~home:
100 139 (home_view ~routine
101 140 ~routine_name:(Prescription.Routine.name selected)
102 Removed: ~next ~readiness)
141 Added: ~next ~readiness ~next_exercises)
103 142 | Error error, _ | _, Error error ->
104 Removed: Pages.problem ~title:"Routine unavailable"
105 Removed: ~detail:(Present.routine error)))
143 Added: Lwt.return
144 Added: (Pages.problem ~title:"Routine unavailable"
145 Added: ~detail:(Present.routine error))))
106 146
107 147 let home t trainee request = page t trainee request >>= html
108 148
test/web/overview.ml
index b4b2df62..5ecc320f 100644..100644
@@ -49,6 +49,76 @@
49 49 Alcotest.(check bool)
50 50 "an upcoming exercise is shown" true
51 51 (Option.is_some (index_from ~needle:"Incline Presses" page 0)) );
52 Added: ( "the home preview shows each exercise's current best weight and reps",
53 Added: `Quick,
54 Added: fun () ->
55 Added: (* Give the Day 1 exercise Laterals a prior effort through an import,
56 Added: which feeds the log but never advances the routine cycle. The next
57 Added: workout stays Day 1, and its preview must show Laterals with the
58 Added: recorded best — the record to beat, per the Heavy Duty progress
59 Added: chart. *)
60 Added: let c, repo = client_with_repo () in
61 Added: let _ = register c ~username:"lifter" ~password:"heavyduty1" in
62 Added: let trainee = the_trainee repo ~username:"lifter" in
63 Added: let token = Option.get (csrf_token (body (get c "/"))) in
64 Added: let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
65 Added: let header =
66 Added: "title,start_time,end_time,description,exercise_title,superset_id,exercise_notes,set_index,set_type,weight_kg,reps,distance_km,duration_seconds,rpe\r\n"
67 Added: in
68 Added: let source =
69 Added: header
70 Added: ^ "Delts,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Side \
71 Added: Raise,,,0,failure,14,10,,,\r\n"
72 Added: in
73 Added: let token = Option.get (csrf_token (body (get c "/profile"))) in
74 Added: let _ =
75 Added: post_multipart c "/profile/import"
76 Added: ~file:(Some ("csv", "workouts.csv", source))
77 Added: ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ]
78 Added: in
79 Added: let batch =
80 Added: List.hd
81 Added: (Lwt_main.run
82 Added: (Memory_repo.import_batches repo trainee.Trainee.id))
83 Added: in
84 Added: let bid =
85 Added: Workout_import.batch_id_to_string
86 Added: (Workout_import.batch_identity batch)
87 Added: in
88 Added: let wid =
89 Added: Workout_import.workout_id_to_string
90 Added: (Workout_import.workout_identity
91 Added: (List.hd (Workout_import.batch_workouts batch)))
92 Added: in
93 Added: let review = "/profile/import/" ^ bid in
94 Added: let t = Option.get (csrf_token (body (get c review))) in
95 Added: let _ =
96 Added: post c
97 Added: (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid)
98 Added: [
99 Added: ("dream.csrf", t);
100 Added: ("source_name", "Side Raise");
101 Added: ("exercise_id", "laterals");
102 Added: ]
103 Added: in
104 Added: let t = Option.get (csrf_token (body (get c review))) in
105 Added: let _ =
106 Added: post c
107 Added: (Printf.sprintf "/profile/import/%s/workouts/%s/promote" bid wid)
108 Added: [ ("dream.csrf", t) ]
109 Added: in
110 Added: let page = body (get c "/") in
111 Added: (* The cycle has not advanced: the next workout is still Day 1. *)
112 Added: Alcotest.(check bool)
113 Added: "the Day 1 preview lists Laterals" true
114 Added: (Option.is_some (index_from ~needle:"Laterals" page 0));
115 Added: Alcotest.(check bool)
116 Added: "Laterals shows its best weight and reps" true
117 Added: (Option.is_some (index_from ~needle:"14 kg × 10 reps" page 0));
118 Added: Alcotest.(check bool)
119 Added: "the best is styled as the record to beat" true
120 Added: (Option.is_some (index_from ~needle:"next-workout-best" page 0))
121 Added: );
52 122 ( "the routine page is served once a routine is selected",
53 123 `Quick,
54 124 fun () ->