[OCaml] High Intensity Training Online
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
Changed files
lib/presenter/pages_logbook.ml
@@ -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
@@ -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
@@ -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
@@ -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. *)