feat Show workout duration with a prior-repeat delta

Show each logbook entry's elapsed time in its detail panel, next to the exercises and actions, not in the collapsed summary line. When an earlier workout exists with the same prescription name (the identical workout, performed on a different day), show a signed delta against it in minutes. Less time is the favourable direction here, unlike a load or reps delta, so a faster repeat colours green and a slower one colours oxblood. A first performance of a workout, and an imported workout (which carries no prescription to match against), show their own duration with no delta. sim: not tracked

Commit
153d798ebf4954c9669fe4ce73e0a8cf5e05b5c5
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 3c3a07d1..f04ffd0b 100644..100644
@@ -142,6 +142,38 @@
142 142 [ class_ "exercise-metric" ]
143 143 ([ txt "%s %s" value unit_ ] @ Option.to_list delta)
144 144
145 Added: (* The workout duration line, with a coloured delta against the most recent
146 Added: earlier performance of the identical prescribed workout. Unlike a weight or
147 Added: reps delta, less time is the favourable direction here — finishing faster at
148 Added: the same or greater intensity is the signal this line exists to show — so a
149 Added: decrease colours green and an increase colours oxblood, the opposite mapping
150 Added: from {!metric_delta}. [None] when there is no such earlier workout or either
151 Added: workout is unfinished. *)
152 Added: let duration_line ~seconds ~previous_seconds =
153 Added: match (seconds, previous_seconds) with
154 Added: | None, _ -> None
155 Added: | Some seconds, None ->
156 Added: Some
157 Added: (metric_line
158 Added: ~value:(Printf.sprintf "%d" (seconds / 60))
159 Added: ~unit_:"min" ~delta:None)
160 Added: | Some seconds, Some previous_seconds ->
161 Added: let delta_seconds = seconds - previous_seconds in
162 Added: let delta =
163 Added: if delta_seconds = 0 then None
164 Added: else
165 Added: let cls = if delta_seconds < 0 then "delta-up" else "delta-down" in
166 Added: let delta_minutes = float_of_int delta_seconds /. 60. in
167 Added: Some
168 Added: (tag "span"
169 Added: [ Dream_html.string_attr "class" "metric-delta %s" cls ]
170 Added: [ txt " (%+.1f min)" delta_minutes ])
171 Added: in
172 Added: Some
173 Added: (metric_line
174 Added: ~value:(Printf.sprintf "%d" (seconds / 60))
175 Added: ~unit_:"min" ~delta)
176 Added:
145 177 (* One exercise block in a row menu: a bold name, then a weight line and a reps
146 178 line, each with its own coloured change. Shared by native and imported
147 179 menus; [prev_*] is [None] for a first performance. *)
@@ -200,6 +232,9 @@
200 232 (List.map imported_effort_line
201 233 entry.View_model.Logbook_entry.imported_efforts);
202 234 ]
235 Added: @ Option.to_list
236 Added: (duration_line ~seconds:entry.duration_seconds
237 Added: ~previous_seconds:entry.duration_previous_seconds)
203 238 else
204 239 let exercises =
205 240 if entry.changes = [] then
@@ -216,6 +251,9 @@
216 251 ]
217 252 in
218 253 exercises
254 Added: @ Option.to_list
255 Added: (duration_line ~seconds:entry.duration_seconds
256 Added: ~previous_seconds:entry.duration_previous_seconds)
219 257 @ [
220 258 tag "div"
221 259 [ class_ "logbook-entry-actions" ]
lib/presenter/view_model.ml
index 662544d2..6c2239ae 100644..100644
@@ -191,6 +191,15 @@
191 191 date : string; (** Workout date, pre-formatted "YYYY-MM-DD" in UTC. *)
192 192 duration : string;
193 193 (** Finished elapsed time, pre-formatted for scanning. *)
194 Added: duration_previous_seconds : int option;
195 Added: (** The most recent earlier workout with the same prescription name,
196 Added: strictly before this one, in seconds, when both finished. [None] for
197 Added: a first performance of this workout or an unfinished one. *)
198 Added: duration_seconds : int option;
199 Added: (** This workout's own elapsed seconds, when finished. Carried alongside
200 Added: the pre-formatted {!duration} so the presenter can derive a signed
201 Added: delta against {!duration_previous_seconds} without re-parsing
202 Added: display text. *)
194 203 changes : Logbook_change.t list;
195 204 (** Per-exercise comparisons for a native workout, in performance order,
196 205 each carrying its own verdict. Empty for an import. *)
lib/web/handlers_logbook.ml
index 0164d3cf..05f562ad 100644..100644
@@ -129,6 +129,28 @@
129 129 else if load < 0 || (load = 0 && reps < 0) then Regressed
130 130 else Stagnant
131 131
132 Added: (* The most recent other finished workout in the log with the same
133 Added: prescription name, started strictly before [workout]. This is the
134 Added: "identical workout" baseline for a duration comparison: same prescribed
135 Added: work, a different day. The baseline rule matches {!Progress}'s per-exercise
136 Added: comparisons, so correcting a saved workout never compares it against
137 Added: itself. *)
138 Added: let previous_same_workout_duration log (workout : Evidence.Workout.t) =
139 Added: let name =
140 Added: Prescription.Workout.name (Evidence.Workout.prescription workout)
141 Added: in
142 Added: let started = Evidence.Workout.started_at workout in
143 Added: Evidence.Log.workouts log
144 Added: |> List.find_opt (fun w ->
145 Added: Recovery.timestamp_to_unix_seconds (Evidence.Workout.started_at w)
146 Added: < Recovery.timestamp_to_unix_seconds started
147 Added: && String.equal
148 Added: (Prescription.Workout.name (Evidence.Workout.prescription w))
149 Added: name)
150 Added: |> Option.map Evidence.Workout.duration
151 Added: |> Option.join
152 Added: |> Option.map Recovery.duration_to_seconds
153 Added:
132 154 let entry_view log (record : Repository.record) : View_model.Logbook_entry.t =
133 155 let workout = record.Repository.workout in
134 156 let started =
@@ -151,12 +173,18 @@
151 173 ~current:c.Progress.current;
152 174 })
153 175 in
176 Added: let duration_seconds =
177 Added: Option.map Recovery.duration_to_seconds
178 Added: (Evidence.Workout.duration workout)
179 Added: in
154 180 let duration =
155 Removed: match Evidence.Workout.duration workout with
181 Added: match duration_seconds with
156 182 | None -> "—"
157 Removed: | Some duration ->
158 Removed: Printf.sprintf "%d min" (Recovery.duration_to_seconds duration / 60)
183 Added: | Some seconds -> Printf.sprintf "%d min" (seconds / 60)
159 184 in
185 Added: let duration_previous_seconds =
186 Added: previous_same_workout_duration log workout
187 Added: in
160 188 {
161 189 id = (record.Repository.id :> string);
162 190 imported = false;
@@ -165,6 +193,8 @@
165 193 Prescription.Workout.name (Evidence.Workout.prescription workout);
166 194 date = utc_date started;
167 195 duration;
196 Added: duration_previous_seconds;
197 Added: duration_seconds;
168 198 changes;
169 199 imported_efforts = [];
170 200 }
@@ -235,6 +265,8 @@
235 265 workout_name = record.Repository.title;
236 266 date = utc_date started;
237 267 duration = Printf.sprintf "%d min" ((ended - started) / 60);
268 Added: duration_previous_seconds = None;
269 Added: duration_seconds = Some (ended - started);
238 270 changes = [];
239 271 imported_efforts = efforts;
240 272 }
test/web/logbook.ml
index 2cc64a4c..731924c8 100644..100644
@@ -37,7 +37,8 @@
37 37 let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
38 38 let page = body (get c "/logbook") in
39 39 (* The logbook is an accordion, one collapsible <details> per
40 Removed: workout. There is no table and no Duration. *)
40 Added: workout. There is no table; the Duration sits in the panel, not
41 Added: the summary. *)
41 42 Alcotest.(check bool)
42 43 "the accordion is present" true
43 44 (has_class page "logbook-entry");
@@ -51,8 +52,73 @@
51 52 "the entry summary carries the title" true
52 53 (Option.is_some (index_from ~needle:"logbook-entry-title" page 0));
53 54 Alcotest.(check bool)
54 Removed: "no duration is shown" false
55 Removed: (Option.is_some (index_from ~needle:" min<" page 0)) );
55 Added: "the duration is shown in the panel, not the summary" true
56 Added: (Option.is_some (index_from ~needle:" min<" page 0));
57 Added: (* A first performance has no identical earlier workout, so no
58 Added: delta accompanies the duration. *)
59 Added: Alcotest.(check bool)
60 Added: "a first performance carries no duration delta" false
61 Added: (Option.is_some (index_from ~needle:"metric-delta" page 0)) );
62 Added: ( "a faster repeat of the identical workout shows a favourable \
63 Added: duration delta",
64 Added: `Quick,
65 Added: fun () ->
66 Added: let c, now, _repo = clocked_client () in
67 Added: now := day 10;
68 Added: let _ = sign_in_new c in
69 Added: let _ = start_day_one c in
70 Added: record_all_day_one_slots c;
71 Added: (* The first Day 1 takes a full hour. *)
72 Added: now := day 10 + 3600;
73 Added: let token = Option.get (csrf_token (body (get c "/workout"))) in
74 Added: let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
75 Added: (* Advance through the cycle until Day 1 comes round again, resting
76 Added: past the 48-hour gate before each begin, exactly as
77 Added: Web.Workout's "the record to beat" test does. Intermediate days
78 Added: are recorded and finished promptly; only the Day 1 repeat's own
79 Added: duration is timed. *)
80 Added: let rec reach_day_one attempts d =
81 Added: if attempts = 0 then Alcotest.failf "Day 1 did not recur"
82 Added: else begin
83 Added: now := day d;
84 Added: let token = Option.get (csrf_token (body (get c "/"))) in
85 Added: let _ =
86 Added: post c "/workout"
87 Added: [ ("dream.csrf", token); ("override", "false") ]
88 Added: in
89 Added: let page = body (get c "/workout") in
90 Added: if Option.is_some (index_from ~needle:">Day 1<" page 0) then ()
91 Added: else begin
92 Added: record_all_day_one_slots c;
93 Added: let token =
94 Added: Option.get (csrf_token (body (get c "/workout")))
95 Added: in
96 Added: let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
97 Added: reach_day_one (attempts - 1) (d + 3)
98 Added: end
99 Added: end
100 Added: in
101 Added: reach_day_one 6 13;
102 Added: (* The Day 1 repeat finishes in forty minutes — faster, at the
103 Added: same effort. *)
104 Added: record_all_day_one_slots c;
105 Added: let finished_at = !now + 2400 in
106 Added: now := finished_at;
107 Added: let token = Option.get (csrf_token (body (get c "/workout"))) in
108 Added: let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
109 Added: let page = body (get c "/logbook") in
110 Added: Alcotest.(check bool)
111 Added: "the faster workout shows its own duration" true
112 Added: (Option.is_some (index_from ~needle:"40 min" page 0));
113 Added: Alcotest.(check bool)
114 Added: "finishing faster colours the delta favourably" true
115 Added: (Option.is_some (index_from ~needle:"delta-up" page 0));
116 Added: (* The comparison is against the other Day 1, not an intermediate
117 Added: day, so the delta is exactly the difference between the two Day
118 Added: 1 durations regardless of how many intermediate days ran. *)
119 Added: Alcotest.(check bool)
120 Added: "the delta states twenty minutes faster" true
121 Added: (Option.is_some (index_from ~needle:"-20.0 min" page 0)) );
56 122 ( "the accordion panel shows exact Heavy Duty progress evidence",
57 123 `Quick,
58 124 fun () ->
@@ -518,9 +584,11 @@
518 584 Alcotest.(check bool)
519 585 "the Imported pill marks the entry" true
520 586 (Option.is_some (index_from ~needle:"logbook-import-pill" page 0));
521 Removed: (* Duration is no longer shown for any workout, native or imported. *)
587 Added: (* An imported workout still shows its own duration in the panel.
588 Added: It has no prescription to match against an earlier identical
589 Added: workout, so no delta accompanies it. *)
522 590 Alcotest.(check bool)
523 Removed: "no duration is shown" false
591 Added: "the imported workout's duration is shown" true
524 592 (Option.is_some (index_from ~needle:"60 min" page 0));
525 593 (* An import's exercises and efforts live in the accordion panel.
526 594 This is a first performance, so no change is shown. *)