style show progress as per-metric lines with coloured deltas

Render each exercise in a row menu as a bold name, a monospaced weight line, and a monospaced reps line, each with an inline change in parentheses coloured by the app palette: an increase is green, a decrease oxblood. A first performance shows the values with no delta. Replaces the single combined delta line for both native and imported entries.

Commit
3492af70fda0470a5fe6b7e750752b6b23af9447
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/pages_logbook.ml
index 11a24302..5a6b5897 100644..100644
@@ -137,73 +137,57 @@
137 137 ];
138 138 ]
139 139
140 Removed: let signed_number n = if n > 0 then Printf.sprintf "+%d" n else string_of_int n
140 Added: (* An inline coloured delta for one metric: green for an increase, oxblood for a
141 Added: decrease, following the app's progress palette. [None] when there is no prior
142 Added: value or the value is unchanged, so no parenthetical is shown. *)
143 Added: let metric_delta ~previous ~current =
144 Added: match previous with
145 Added: | None -> None
146 Added: | Some p ->
147 Added: let d = current -. p in
148 Added: if Float.equal d 0. then None
149 Added: else
150 Added: let cls = if d > 0. then "delta-up" else "delta-down" in
151 Added: Some
152 Added: (tag "span"
153 Added: [ Dream_html.string_attr "class" "metric-delta %s" cls ]
154 Added: [ txt " (%+g)" d ])
141 155
142 Removed: let change_delta (change : View_model.Logbook_change.t) =
143 Removed: let load = change.current_load -. change.previous_load in
144 Removed: let reps = change.current_reps - change.previous_reps in
145 Removed: let pieces =
146 Removed: (if Float.equal load 0. then [] else [ Printf.sprintf "%+g kg" load ])
147 Removed: @
148 Removed: if reps = 0 then []
149 Removed: else
150 Removed: [
151 Removed: Printf.sprintf "%s rep%s" (signed_number reps)
152 Removed: (if abs reps = 1 then "" else "s");
153 Removed: ]
154 Removed: in
155 Removed: if pieces = [] then "no change" else String.concat ", " pieces
156 Added: (* One metric line: monospaced "<value> <unit>" with an optional coloured delta.
157 Added: [unit_] is pluralised by the caller. *)
158 Added: let metric_line ~value ~unit_ ~delta =
159 Added: tag "p"
160 Added: [ class_ "exercise-metric" ]
161 Added: ([ txt "%s %s" value unit_ ] @ Option.to_list delta)
156 162
157 Removed: (* The colour class for a per-exercise verdict: progress oxblood, regress green,
158 Removed: stagnation brass. A first performance (no verdict) is neutral. *)
159 Removed: let verdict_class = function
160 Removed: | Some View_model.Progressed -> "verdict-progress"
161 Removed: | Some View_model.Regressed -> "verdict-regress"
162 Removed: | Some View_model.Stagnant -> "verdict-stagnant"
163 Removed: | None -> "verdict-none"
164 Removed:
165 Removed: (* One exercise line in a row menu: name, the efforts, and a colour-coded
166 Removed: delta. Shared by native and imported menus. *)
167 Removed: let exercise_line ~name ~efforts ~verdict ~delta =
163 Added: (* One exercise block in a row menu: a bold name, then a weight line and a reps
164 Added: line, each with its own coloured change. Shared by native and imported
165 Added: menus; [prev_*] is [None] for a first performance. *)
166 Added: let exercise_block ~name ~weight ~reps ~prev_weight ~prev_reps =
168 167 tag "li"
169 Removed: [ Dream_html.string_attr "class" "%s" (verdict_class verdict) ]
168 Added: [ class_ "exercise-block" ]
170 169 [
171 170 tag "strong" [] [ txt "%s" name ];
172 Removed: tag "span" [ class_ "change-efforts" ] [ txt "%s" efforts ];
173 Removed: tag "span" [ class_ "change-delta" ] [ txt "%s" delta ];
171 Added: metric_line
172 Added: ~value:(Printf.sprintf "%g" weight)
173 Added: ~unit_:"kg"
174 Added: ~delta:(metric_delta ~previous:prev_weight ~current:weight);
175 Added: metric_line ~value:(string_of_int reps)
176 Added: ~unit_:(if reps = 1 then "rep" else "reps")
177 Added: ~delta:
178 Added: (metric_delta
179 Added: ~previous:(Option.map float_of_int prev_reps)
180 Added: ~current:(float_of_int reps));
174 181 ]
175 182
176 183 let native_change_line (change : View_model.Logbook_change.t) =
177 Removed: exercise_line ~name:change.exercise_name
178 Removed: ~efforts:
179 Removed: (Printf.sprintf "%g kg × %d → %g kg × %d" change.previous_load
180 Removed: change.previous_reps change.current_load change.current_reps)
181 Removed: ~verdict:(Some change.verdict) ~delta:(change_delta change)
184 Added: exercise_block ~name:change.exercise_name ~weight:change.current_load
185 Added: ~reps:change.current_reps ~prev_weight:(Some change.previous_load)
186 Added: ~prev_reps:(Some change.previous_reps)
182 187
183 Removed: (* The progress since an imported effort was last performed, as a compact
184 Removed: delta. *)
185 Removed: let imported_delta (e : View_model.Imported_effort.t) =
186 Removed: match (e.previous_load, e.previous_reps) with
187 Removed: | Some pl, Some pr ->
188 Removed: let load = e.load -. pl in
189 Removed: let reps = e.reps - pr in
190 Removed: let pieces =
191 Removed: (if Float.equal load 0. then [] else [ Printf.sprintf "%+g kg" load ])
192 Removed: @
193 Removed: if reps = 0 then []
194 Removed: else
195 Removed: [
196 Removed: Printf.sprintf "%s rep%s" (signed_number reps)
197 Removed: (if abs reps = 1 then "" else "s");
198 Removed: ]
199 Removed: in
200 Removed: if pieces = [] then "no change" else String.concat ", " pieces
201 Removed: | _ -> "first performance"
202 Removed:
203 188 let imported_effort_line (e : View_model.Imported_effort.t) =
204 Removed: exercise_line ~name:e.exercise_name
205 Removed: ~efforts:(Printf.sprintf "%g kg × %d" e.load e.reps)
206 Removed: ~verdict:e.verdict ~delta:(imported_delta e)
189 Added: exercise_block ~name:e.exercise_name ~weight:e.load ~reps:e.reps
190 Added: ~prev_weight:e.previous_load ~prev_reps:e.previous_reps
207 191
208 192 (* The three-dot menu shared shape: a summary button opening a drawer. *)
209 193 let row_menu ~aria_summary ~aria_drawer ~extra_class contents =
lib/web/assets/hito.css
index e0ff0f39..6bb7deda 100644..100644
@@ -702,33 +702,26 @@
702 702 }
703 703 .logbook-change-list {
704 704 display: grid;
705 Removed: gap: 0.4rem;
705 Added: gap: 0.75rem;
706 706 margin: 0;
707 707 padding: 0;
708 708 list-style: none;
709 709 }
710 Removed: .logbook-change-list li {
711 Removed: display: grid;
712 Removed: grid-template-columns: 1fr auto;
713 Removed: gap: 0.25rem 0.5rem;
714 Removed: align-items: baseline;
710 Added: /* One exercise: a bold name, then a weight line and a reps line. */
711 Added: .exercise-block > strong {
712 Added: display: block;
713 Added: color: var(--ink);
715 714 }
716 Removed: .change-efforts {
717 Removed: grid-column: 1 / -1;
715 Added: .exercise-metric {
716 Added: margin: 0.15rem 0 0;
718 717 font-family: var(--mono);
719 Removed: white-space: nowrap;
718 Added: color: var(--ink);
720 719 }
721 Removed: .change-delta {
722 Removed: color: var(--oxblood-dark);
723 Removed: font: 700 var(--font-size-small)/1 var(--sans);
724 Removed: white-space: nowrap;
725 Removed: }
726 Removed: /* Per-exercise progress verdict colours in the row menu: progress green,
727 Removed: regress oxblood, stagnation brass. The colour lands on that line's delta. */
728 Removed: .verdict-progress .change-delta { color: var(--evergreen); }
729 Removed: .verdict-regress .change-delta { color: var(--oxblood-dark); }
730 Removed: .verdict-stagnant .change-delta { color: var(--brass); }
731 Removed: .verdict-none .change-delta { color: var(--muted-ink); }
720 Added: /* The per-metric change, coloured by the app's progress palette: an increase
721 Added: is green, a decrease oxblood. */
722 Added: .metric-delta { font-weight: 700; }
723 Added: .metric-delta.delta-up { color: var(--evergreen); }
724 Added: .metric-delta.delta-down { color: var(--oxblood-dark); }
732 725
733 726 /* The action bar under the logbook heading. */
734 727 .logbook-actions-bar { margin: 0.5rem 0 1rem; }
test/web/logbook.ml
index 525def18..3160342e 100644..100644
@@ -103,9 +103,11 @@
103 103 [
104 104 "18 min";
105 105 "Laterals";
106 Removed: "10 kg × 8 → 12 kg × 9";
107 Removed: "+2 kg, +1 rep";
108 Removed: "verdict-progress";
106 Added: "12 kg";
107 Added: "(+2)";
108 Added: "9 reps";
109 Added: "(+1)";
110 Added: "delta-up";
109 111 ] );
110 112 ( "each saved workout row has an edit and confirmation-gated delete menu",
111 113 `Quick,
@@ -522,8 +524,8 @@
522 524 "the imported workout duration is shown" true
523 525 (Option.is_some (index_from ~needle:"60 min" page 0));
524 526 (* An import's status and detail live in a three-dot menu, whose
525 Removed: drawer lists the imported exercise, its effort, and the progress
526 Removed: since it was last performed (here a first performance). *)
527 Added: drawer lists the imported exercise and its effort. This is a first
528 Added: performance, so no change is shown. *)
527 529 Alcotest.(check bool)
528 530 "the import three-dot menu drawer is present" true
529 531 (Option.is_some
@@ -532,9 +534,13 @@
532 534 "the imported exercise is listed" true
533 535 (Option.is_some (index_from ~needle:"Squats" page 0));
534 536 Alcotest.(check bool)
535 Removed: "a first performance is marked as such" true
536 Removed: (Option.is_some (index_from ~needle:"first performance" page 0));
537 Added: "the effort metrics are shown" true
538 Added: (Option.is_some (index_from ~needle:"100 kg" page 0)
539 Added: && Option.is_some (index_from ~needle:"8 reps" page 0));
537 540 Alcotest.(check bool)
541 Added: "a first performance shows no change" false
542 Added: (Option.is_some (index_from ~needle:"metric-delta" page 0));
543 Added: Alcotest.(check bool)
538 544 "a read-only import offers no delete action" false
539 545 (Option.is_some (index_from ~needle:"?delete=" page 0)) );
540 546 ( "an imported exercise shows progress since it was last performed",
@@ -602,10 +608,14 @@
602 608 (Lwt_main.run
603 609 (Memory_repo.imported_records repo trainee.Trainee.id)));
604 610 let page = body (get c "/logbook?year=2024&month=1") in
605 Removed: (* The later import gained 10 kg over the earlier one. *)
611 Added: (* The later import gained 10 kg over the earlier one, shown as a
612 Added: green weight delta. *)
606 613 Alcotest.(check bool)
607 Removed: "the load gain since last performed is shown" true
608 Removed: (Option.is_some (index_from ~needle:"+10 kg" page 0)) );
614 Added: "the load gain is shown as a delta" true
615 Added: (Option.is_some (index_from ~needle:"(+10)" page 0));
616 Added: Alcotest.(check bool)
617 Added: "the gain is coloured as an increase" true
618 Added: (Option.is_some (index_from ~needle:"delta-up" page 0)) );
609 619 ( "the progress page charts an exercise with enough history",
610 620 `Quick,
611 621 fun () ->