[OCaml] High Intensity Training Online
feat show the elapsed timer in the nav on every page
Replace the sticky top timer bar with the elapsed time in the middle navigation slot, so a running workout is a persistent reminder on every page, not only the workout screen. The page layer now carries the workout's start epoch (?workout_started) in place of a logging boolean, so the state cannot be misrepresented: the timer shows exactly when a start time is present. Each handler derives the epoch from the in-progress workout. The client ticks every timer node, covering the desktop and mobile navs.
Changed files
- lib/presenter/page_shell.ml
- lib/presenter/pages.mli
- lib/presenter/pages_app_feedback.ml
- lib/presenter/pages_logbook.ml
- lib/presenter/pages_profile.ml
- lib/presenter/pages_progress.ml
- lib/presenter/pages_routine.ml
- lib/presenter/pages_settings.ml
- lib/presenter/pages_workout.ml
- lib/web/assets/hito.css
- lib/web/handlers_app_feedback.ml
- lib/web/handlers_kit.ml
- lib/web/handlers_logbook.ml
- lib/web/handlers_overview.ml
- lib/web/handlers_profile.ml
- lib/web/handlers_settings.ml
- lib/web/workout_client.ml
- test/web/workout.ml
lib/presenter/page_shell.ml
@@ -25,9 +25,12 @@
25
25
[ txt "%s" (deployment_version ()) ];
26
26
]
27
27
28
Removed:
let html_page ?viewer ?request ?(active = "") ?(logging = false)
29
Removed:
?(show_nav = false) title content =
28
Added:
let html_page ?viewer ?request ?(active = "") ?(show_nav = false)
29
Added:
?workout_started title content =
30
30
let spa_client = true in
31
Added:
(* A workout is being logged exactly when its start epoch is present. The
32
Added:
middle nav slot then shows the elapsed timer on every page, so the running
33
Added:
workout is always visible; otherwise it is the routine link. *)
31
34
let theme =
32
35
match request with
33
36
| Some request -> (
@@ -50,14 +53,43 @@
50
53
tag "a" attrs [ txt "%s" label ]
51
54
in
52
55
(* Three always-available actions. The middle slot points to the routine
53
Removed:
normally and to the workout in progress while logging, so a running
54
Removed:
workout stays one tap away without adding a fourth destination. The logbook
55
Removed:
tab always reads "Logbook". The username lives on its own profile control. *)
56
Added:
normally. While logging it becomes the elapsed workout timer — a link that
57
Added:
still carries the trainee to the current workout, so a running workout
58
Added:
stays one tap away without adding a fourth destination, and the timer needs
59
Added:
no separate bar. The client ticks the value from [data-started]; without
60
Added:
script the link shows a static start. The logbook tab always reads
61
Added:
"Logbook". The username lives on its own profile control. *)
62
Added:
let workout_timer_link started =
63
Added:
let attrs =
64
Added:
[ href Routes.workout; class_ "nav-timer" ]
65
Added:
@ (if spa_client then [ Dream_html.attr "data-hito-app-link" ] else [])
66
Added:
@ (if String.equal "workout" active then
67
Added:
[ Dream_html.string_attr "aria-current" "page" ]
68
Added:
else [])
69
Added:
@ [
70
Added:
Dream_html.attr "data-hito-workout-timer";
71
Added:
Dream_html.string_attr "data-started" "%d" started;
72
Added:
Dream_html.string_attr "role" "timer";
73
Added:
Dream_html.string_attr "aria-label" "Elapsed workout time";
74
Added:
]
75
Added:
in
76
Added:
tag "a" attrs
77
Added:
[
78
Added:
tag "span"
79
Added:
[
80
Added:
class_ "nav-timer-value";
81
Added:
Dream_html.string_attr "aria-live" "off";
82
Added:
Dream_html.attr "data-hito-workout-timer-value";
83
Added:
]
84
Added:
[ txt "0:00" ];
85
Added:
]
86
Added:
in
56
87
let navigation_links () =
57
88
[
58
89
nav_link "home" Routes.home "Home";
59
Removed:
(if logging then nav_link "workout" Routes.workout "Current workout"
60
Removed:
else nav_link "routine" Routes.routine "Routine");
90
Added:
(match workout_started with
91
Added:
| Some started -> workout_timer_link started
92
Added:
| None -> nav_link "routine" Routes.routine "Routine");
61
93
nav_link "logbook" Routes.logbook "Logbook";
62
94
]
63
95
in
lib/presenter/pages.mli
@@ -8,7 +8,7 @@
8
8
9
9
val choose_routine :
10
10
Dream.request ->
11
Removed:
logging:bool ->
11
Added:
workout_started:int option ->
12
12
viewer:View_model.Viewer.t ->
13
13
routines:View_model.Routine_choice.t list ->
14
14
page
@@ -17,13 +17,17 @@
17
17
Dream.request -> viewer:View_model.Viewer.t -> home:View_model.Home.t -> page
18
18
19
19
val workout_in_progress :
20
Removed:
Dream.request -> viewer:View_model.Viewer.t -> workout_name:string -> page
20
Added:
Dream.request ->
21
Added:
viewer:View_model.Viewer.t ->
22
Added:
workout_started:int ->
23
Added:
workout_name:string ->
24
Added:
page
21
25
(** The Home card shown while a workout is in progress. Links to the current
22
Removed:
workout so it stays one tap away. *)
26
Added:
workout so it stays one tap away. [workout_started] drives the nav timer. *)
23
27
24
28
val routine :
25
29
Dream.request ->
26
Removed:
?logging:bool ->
30
Added:
?workout_started:int ->
27
31
viewer:View_model.Viewer.t ->
28
32
View_model.Routine.t ->
29
33
page
@@ -33,7 +37,7 @@
33
37
viewer:View_model.Viewer.t ->
34
38
?errors:(string * string) list ->
35
39
?editing:int ->
36
Removed:
?logging:bool ->
40
Added:
?workout_started:int ->
37
41
record_id:string option ->
38
42
active_slot:int ->
39
43
View_model.Workout.t ->
@@ -45,9 +49,10 @@
45
49
saved-record edits use the generic authenticated form path. [active_slot] is
46
50
the slot that opens. A handler clamps a requested slot and falls back to the
47
51
first outstanding slot. [record_id] is [None] for the workout in progress
48
Removed:
and [Some id] for a saved history record. [logging] sets the middle
49
Removed:
navigation action. It defaults to a live workout view (true when [record_id]
50
Removed:
is [None]). *)
52
Added:
and [Some id] for a saved history record. [workout_started] drives the nav
53
Added:
timer: a live workout uses its own start, so it is ignored there; a
54
Added:
saved-record view passes the epoch of a separate workout in progress, if
55
Added:
any. *)
51
56
52
57
val feedback_factors : (string * string) list
53
58
(** Ordered factor codes and labels. Each is a radio group in the feedback form
@@ -55,7 +60,7 @@
55
60
56
61
val logbook :
57
62
Dream.request ->
58
Removed:
?logging:bool ->
63
Added:
?workout_started:int ->
59
64
viewer:View_model.Viewer.t ->
60
65
?calendar:View_model.Calendar.t ->
61
66
?suggest_feedback:bool ->
@@ -73,7 +78,7 @@
73
78
74
79
val progress :
75
80
Dream.request ->
76
Removed:
?logging:bool ->
81
Added:
?workout_started:int ->
77
82
viewer:View_model.Viewer.t ->
78
83
View_model.Progress_page.t ->
79
84
page
@@ -83,7 +88,7 @@
83
88
84
89
val profile :
85
90
Dream.request ->
86
Removed:
?logging:bool ->
91
Added:
?workout_started:int ->
87
92
viewer:View_model.Viewer.t ->
88
93
?username_error:string ->
89
94
?password_error:string ->
@@ -96,7 +101,7 @@
96
101
97
102
val settings :
98
103
Dream.request ->
99
Removed:
?logging:bool ->
104
Added:
?workout_started:int ->
100
105
viewer:View_model.Viewer.t ->
101
106
theme:string ->
102
107
unit ->
@@ -128,7 +133,7 @@
128
133
129
134
val app_feedback :
130
135
Dream.request ->
131
Removed:
?logging:bool ->
136
Added:
?workout_started:int ->
132
137
viewer:View_model.Viewer.t ->
133
138
?tab:[ `Write | `Submitted ] ->
134
139
?error:string ->
lib/presenter/pages_app_feedback.ml
@@ -136,8 +136,8 @@
136
136
[ class_ "app-feedback-actions" ]
137
137
[ app_feedback_vote_form request report ]
138
138
139
Removed:
let app_feedback request ?(logging = false) ~viewer ?(tab = `Write) ?error
140
Removed:
reports =
139
Added:
let app_feedback request ?workout_started ~viewer ?(tab = `Write) ?error reports
140
Added:
=
141
141
let panel =
142
142
match tab with
143
143
| `Write ->
@@ -184,7 +184,8 @@
184
184
reports));
185
185
]
186
186
in
187
Removed:
html_page ~viewer ~request ~active:"app-feedback" ~logging "App feedback"
187
Added:
html_page ~viewer ~request ~active:"app-feedback" ?workout_started
188
Added:
"App feedback"
188
189
[
189
190
tag "div" []
190
191
([
lib/presenter/pages_logbook.ml
@@ -342,11 +342,11 @@
342
342
];
343
343
]
344
344
345
Removed:
let logbook request ?(logging = false) ~viewer ?calendar
345
Added:
let logbook request ?workout_started ~viewer ?calendar
346
346
?(suggest_feedback = false) ?(feedback_open = false) ?delete_record
347
347
?expanded_record records =
348
348
ignore expanded_record;
349
Removed:
html_page ~viewer ~request ~active:"logbook" ~logging "Logbook"
349
Added:
html_page ~viewer ~request ~active:"logbook" ?workout_started "Logbook"
350
350
([
351
351
tag "h1" [] [ txt "Logbook" ];
352
352
(* Actions above the ledger, so they are reachable without scrolling past
lib/presenter/pages_profile.ml
@@ -6,7 +6,7 @@
6
6
independent forms: rename the account, and change the password. Each form
7
7
reports its own error inline. [notice] confirms a successful change. Both are
8
8
plain authenticated app-forms, so they work with or without script. *)
9
Removed:
let profile request ?(logging = false) ~viewer ?username_error ?password_error
9
Added:
let profile request ?workout_started ~viewer ?username_error ?password_error
10
10
?notice () =
11
11
let notice_block =
12
12
match notice with
@@ -22,7 +22,7 @@
22
22
]
23
23
| None -> []
24
24
in
25
Removed:
html_page ~viewer ~request ~active:"profile" ~logging "Profile"
25
Added:
html_page ~viewer ~request ~active:"profile" ?workout_started "Profile"
26
26
([ tag "h1" [] [ txt "Profile" ] ]
27
27
@ notice_block
28
28
@ [
lib/presenter/pages_progress.ml
@@ -21,9 +21,9 @@
21
21
Svg_chart.render chart;
22
22
]
23
23
24
Removed:
let progress request ?(logging = false) ~viewer
25
Removed:
(vm : View_model.Progress_page.t) =
26
Removed:
html_page ~viewer ~request ~active:"logbook" ~logging "Progress"
24
Added:
let progress request ?workout_started ~viewer (vm : View_model.Progress_page.t)
25
Added:
=
26
Added:
html_page ~viewer ~request ~active:"logbook" ?workout_started "Progress"
27
27
(tag "h1" [] [ txt "Progress" ]
28
28
::
29
29
(if vm.charts = [] then
lib/presenter/pages_routine.ml
@@ -2,9 +2,9 @@
2
2
open Page_kit
3
3
open Page_shell
4
4
5
Removed:
let choose_routine request ~logging ~viewer
5
Added:
let choose_routine request ~workout_started ~viewer
6
6
~(routines : View_model.Routine_choice.t list) =
7
Removed:
html_page ~viewer ~request ~active:"home" ~logging "Choose a routine"
7
Added:
html_page ~viewer ~request ~active:"home" ?workout_started "Choose a routine"
8
8
[
9
9
tag "h1" [] [ txt "Routines" ];
10
10
tag "div" []
@@ -154,8 +154,8 @@
154
154
routine chooser, Home shows a single card: the workout is under way and one
155
155
tap returns to it. The middle navigation action already reads "Current
156
156
workout" here, and this card is the matching Home affordance. *)
157
Removed:
let workout_in_progress request ~viewer ~workout_name =
158
Removed:
html_page ~viewer ~request ~active:"home" ~logging:true "Home"
157
Added:
let workout_in_progress request ~viewer ~workout_started ~workout_name =
158
Added:
html_page ~viewer ~request ~active:"home" ~workout_started "Home"
159
159
[
160
160
tag "a"
161
161
[
@@ -170,8 +170,8 @@
170
170
];
171
171
]
172
172
173
Removed:
let routine request ?(logging = false) ~viewer (vm : View_model.Routine.t) =
174
Removed:
html_page ~viewer ~request ~active:"routine" ~logging "Routine"
173
Added:
let routine request ?workout_started ~viewer (vm : View_model.Routine.t) =
174
Added:
html_page ~viewer ~request ~active:"routine" ?workout_started "Routine"
175
175
(tag "h1" [] [ txt "%s" vm.name ]
176
176
:: tag "p"
177
177
[ class_ "button-group" ]
lib/presenter/pages_settings.ml
@@ -4,13 +4,13 @@
4
4
5
5
(* The settings page. It currently stores the user's light or dark theme choice
6
6
in the session. *)
7
Removed:
let settings request ?(logging = false) ~viewer ~theme () =
7
Added:
let settings request ?workout_started ~viewer ~theme () =
8
8
let next_theme, label =
9
9
match theme with
10
10
| "dark" -> ("light", "Use light theme")
11
11
| _ -> ("dark", "Use dark theme")
12
12
in
13
Removed:
html_page ~viewer ~request ~active:"settings" ~logging "Settings"
13
Added:
html_page ~viewer ~request ~active:"settings" ?workout_started "Settings"
14
14
[
15
15
tag "h1" [] [ txt "Settings" ];
16
16
tag "section"
lib/presenter/pages_workout.ml
@@ -237,13 +237,15 @@
237
237
]);
238
238
]
239
239
240
Removed:
let workout request ~viewer ?(errors = []) ?editing ?logging ~record_id
240
Added:
let workout request ~viewer ?(errors = []) ?editing ?workout_started ~record_id
241
241
~active_slot (vm : View_model.Workout.t) =
242
242
let enhanced = Option.is_none record_id in
243
Removed:
(* A live workout view is itself the workout in progress, so the middle nav
244
Removed:
action reads "Current workout". A saved-record view defers to the handler,
245
Removed:
which knows whether a separate workout is in progress. *)
246
Removed:
let logging = Option.value logging ~default:enhanced in
243
Added:
(* A live workout view is itself the workout in progress, so its own start
244
Added:
drives the nav timer. A saved-record view is history: the timer, if any,
245
Added:
belongs to a separate workout the handler reports through [workout_started]. *)
246
Added:
let workout_started =
247
Added:
if enhanced then Some vm.started_at_unix else workout_started
248
Added:
in
247
249
let record_action slot =
248
250
match record_id with
249
251
| None -> action Routes.workout_slot slot
@@ -325,34 +327,6 @@
325
327
]
326
328
else []
327
329
in
328
Removed:
(* A sticky timer bar for the workout in progress. It carries the workout's
329
Removed:
start time as an epoch, and the client ticks the elapsed time from it.
330
Removed:
Reading the true start on every render means an SPA content swap never
331
Removed:
resets the count. Only the live workout shows it. *)
332
Removed:
let timer_bar =
333
Removed:
if enhanced then
334
Removed:
let started = vm.started_at_unix in
335
Removed:
[
336
Removed:
tag "div"
337
Removed:
[
338
Removed:
class_ "workout-timer";
339
Removed:
Dream_html.attr "data-hito-workout-timer";
340
Removed:
Dream_html.string_attr "data-started" "%d" started;
341
Removed:
Dream_html.string_attr "role" "timer";
342
Removed:
Dream_html.string_attr "aria-label" "Elapsed workout time";
343
Removed:
]
344
Removed:
[
345
Removed:
tag "span"
346
Removed:
[
347
Removed:
class_ "workout-timer-value";
348
Removed:
Dream_html.string_attr "aria-live" "off";
349
Removed:
Dream_html.attr "data-hito-workout-timer-value";
350
Removed:
]
351
Removed:
[ txt "0:00" ];
352
Removed:
];
353
Removed:
]
354
Removed:
else []
355
Removed:
in
356
330
let finish_section =
357
331
match record_id with
358
332
| Some _ -> []
@@ -430,7 +404,7 @@
430
404
];
431
405
]
432
406
in
433
Removed:
html_page ~viewer ~request ~active:"workout" ~logging vm.name
407
Added:
html_page ~viewer ~request ~active:"workout" ?workout_started vm.name
434
408
[
435
409
tag "div"
436
410
(if enhanced then
@@ -439,8 +413,7 @@
439
413
Dream_html.attr "data-hito-workout-content";
440
414
]
441
415
else [])
442
Removed:
(timer_bar
443
Removed:
@ [ tag "h1" [] [ txt "%s" vm.name ] ]
416
Added:
([ tag "h1" [] [ txt "%s" vm.name ] ]
444
417
@ slots @ complete_note @ finish_section
445
418
@
446
419
if enhanced then
lib/web/assets/hito.css
@@ -1191,27 +1191,14 @@
1191
1191
.slot-accordion::details-content { transition: none; }
1192
1192
}
1193
1193
1194
Removed:
/* The sticky workout timer bar. It pins to the top of the scroll area while a
1195
Removed:
workout is being logged and shows the elapsed time as a centered Courier
1196
Removed:
Prime clock. The client ticks the value from the workout's start time;
1197
Removed:
server-rendered markup shows a static start so the bar is present without
1198
Removed:
script. */
1199
Removed:
.workout-timer {
1200
Removed:
position: sticky;
1201
Removed:
z-index: 10;
1202
Removed:
top: 0;
1203
Removed:
display: flex;
1204
Removed:
align-items: center;
1205
Removed:
justify-content: center;
1206
Removed:
margin: 0 0 1rem;
1207
Removed:
border-bottom: 2px solid var(--rule-strong);
1208
Removed:
background: var(--paper);
1209
Removed:
padding: 0.7rem 0;
1210
Removed:
}
1211
Removed:
.workout-timer-value {
1212
Removed:
color: var(--ink);
1213
Removed:
font: 700 var(--font-size-h3)/1 var(--mono);
1194
Added:
/* The elapsed workout timer lives in the middle nav slot while logging, in
1195
Added:
place of the routine link. It is a nav link like the others, so it inherits
1196
Added:
the nav layout and the active oxblood fill; only its value reads as a
1197
Added:
monospace clock with tabular numerals so the digits do not jitter. */
1198
Added:
.nav-timer-value {
1199
Added:
font: 700 var(--font-size-base)/1 var(--mono);
1214
1200
font-variant-numeric: tabular-nums;
1201
Added:
letter-spacing: 0.02em;
1215
1202
}
1216
1203
1217
1204
/* The cancel confirmation modal. A native dialog styled as a raised paper
@@ -1418,18 +1405,6 @@
1418
1405
}
1419
1406
1420
1407
.page-surface { min-height: calc(100vh - 9rem); padding: 1.25rem; }
1421
Removed:
1422
Removed:
/* On mobile the elapsed bar mirrors the bottom navigation visually: it spans
1423
Removed:
the full viewport width (breaking out of the page-surface 1.25rem and
1424
Removed:
app-shell 0.75rem side padding, then re-padding by the same 2rem), and
1425
Removed:
carries the bottom-nav's paper background, hairline border, and elevation
1426
Removed:
shadow — flipped to the top edge. It stays sticky, not fixed. */
1427
Removed:
.workout-timer {
1428
Removed:
margin-inline: -2rem;
1429
Removed:
padding-inline: 2rem;
1430
Removed:
border-bottom: 1px solid var(--rule);
1431
Removed:
box-shadow: 0 0.35rem 1.2rem rgba(26, 26, 22, 0.15);
1432
Removed:
}
1433
1408
}
1434
1409
1435
1410
@media (max-width: 26rem) {
lib/web/handlers_app_feedback.ml
@@ -46,7 +46,7 @@
46
46
Service.app_feedback t.service trainee.Trainee.id >>= fun reports ->
47
47
html
48
48
(Pages.app_feedback request
49
Removed:
~logging:(Option.is_some in_progress)
49
Added:
?workout_started:(workout_started in_progress)
50
50
~viewer ~tab:(tab request) (List.map view_of reports))
51
51
52
52
let submit t trainee request =
lib/web/handlers_kit.ml
@@ -215,6 +215,15 @@
215
215
| (slot, _) :: _ -> slot
216
216
| [] -> 0
217
217
218
Added:
(* The nav timer's start epoch for a workout in progress, or [None] when no
219
Added:
workout is being logged. Every page passes this so the middle nav slot
220
Added:
shows the running workout's elapsed time regardless of the current page. *)
221
Added:
let workout_started in_progress =
222
Added:
Option.map
223
Added:
(fun workout ->
224
Added:
Recovery.timestamp_to_unix_seconds (Evidence.Workout.started_at workout))
225
Added:
in_progress
226
Added:
218
227
(* The slot a workout view should open on. An explicit [?slot=] query
219
228
wins when it names a real slot. Otherwise the view defaults to the first
220
229
incomplete slot. A [?slot=] out of range, or absent, falls back to the
lib/web/handlers_logbook.ml
@@ -306,7 +306,7 @@
306
306
let calendar = calendar_view ~now ~year ~month all_records in
307
307
html
308
308
(Pages.logbook request
309
Removed:
~logging:(Option.is_some in_progress)
309
Added:
?workout_started:(workout_started in_progress)
310
310
~viewer ~calendar
311
311
~suggest_feedback:
312
312
(match Dream.query request "prompt" with
@@ -346,7 +346,7 @@
346
346
let active_slot = active_slot request workout in
347
347
html
348
348
(Pages.workout request ~viewer
349
Removed:
~logging:(Option.is_some in_progress)
349
Added:
?workout_started:(workout_started in_progress)
350
350
~record_id:(Some id) ~active_slot
351
351
(workout_view ~active_slot ~log workout))
352
352
@@ -458,7 +458,7 @@
458
458
}
459
459
in
460
460
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
461
Removed:
let logging = Option.is_some in_progress in
461
Added:
let started = workout_started in_progress in
462
462
(* One history fetch per catalog exercise, kept only when it can draw. *)
463
463
Lwt_list.filter_map_s
464
464
(fun exercise ->
@@ -474,7 +474,7 @@
474
474
Exercise.catalog
475
475
>>= fun charts ->
476
476
html
477
Removed:
(Pages.progress request ~logging ~viewer
477
Added:
(Pages.progress request ?workout_started:started ~viewer
478
478
{ View_model.Progress_page.charts })
479
479
480
480
let routes t =
lib/web/handlers_overview.ml
@@ -73,12 +73,17 @@
73
73
let workout_name =
74
74
Prescription.Workout.name (Evidence.Workout.prescription workout)
75
75
in
76
Removed:
Lwt.return (Pages.workout_in_progress request ~viewer ~workout_name)
76
Added:
Lwt.return
77
Added:
(Pages.workout_in_progress request ~viewer
78
Added:
~workout_started:
79
Added:
(Recovery.timestamp_to_unix_seconds
80
Added:
(Evidence.Workout.started_at workout))
81
Added:
~workout_name)
77
82
| None -> (
78
83
Service.active_routine t.service trainee.Trainee.id >>= function
79
84
| None ->
80
85
Lwt.return
81
Removed:
(Pages.choose_routine request ~logging:false ~viewer
86
Added:
(Pages.choose_routine request ~workout_started:None ~viewer
82
87
~routines:
83
88
(List.map routine_choice_view
84
89
(Service.list_routines t.service)))
@@ -111,7 +116,7 @@
111
116
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
112
117
html
113
118
(Pages.choose_routine request
114
Removed:
~logging:(Option.is_some in_progress)
119
Added:
~workout_started:(workout_started in_progress)
115
120
~viewer
116
121
~routines:
117
122
(List.map routine_choice_view (Service.list_routines t.service)))
@@ -144,7 +149,7 @@
144
149
| Some (_, routine) ->
145
150
html
146
151
(Pages.routine request
147
Removed:
~logging:(Option.is_some in_progress)
152
Added:
?workout_started:(workout_started in_progress)
148
153
~viewer (routine_view routine))
149
154
| None -> redirect_to request Routes.routines
150
155
lib/web/handlers_profile.ml
@@ -27,7 +27,7 @@
27
27
in
28
28
html
29
29
(Pages.profile request
30
Removed:
~logging:(Option.is_some in_progress)
30
Added:
?workout_started:(workout_started in_progress)
31
31
~viewer ?notice ())
32
32
33
33
let render_error t trainee request ?username_error ?password_error () =
@@ -40,7 +40,7 @@
40
40
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
41
41
html ~status:`Bad_Request
42
42
(Pages.profile request
43
Removed:
~logging:(Option.is_some in_progress)
43
Added:
?workout_started:(workout_started in_progress)
44
44
~viewer ?username_error ?password_error ())
45
45
46
46
let change_username t trainee request =
lib/web/handlers_settings.ml
@@ -26,7 +26,7 @@
26
26
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
27
27
html
28
28
(Pages.settings request
29
Removed:
~logging:(Option.is_some in_progress)
29
Added:
?workout_started:(workout_started in_progress)
30
30
~viewer ~theme:(theme request) ())
31
31
32
32
let update _trainee request =
lib/web/workout_client.ml
@@ -11,6 +11,23 @@
11
11
| None -> None
12
12
| Some node -> Js.Opt.to_option (Dom_html.CoerceTo.element node)
13
13
14
Added:
let query_all root selector =
15
Added:
let nodes = root##querySelectorAll (Js.string selector) in
16
Added:
let rec collect i acc =
17
Added:
if i < 0 then acc
18
Added:
else
19
Added:
let acc =
20
Added:
match Js.Opt.to_option (nodes##item i) with
21
Added:
| None -> acc
22
Added:
| Some node -> (
23
Added:
match Js.Opt.to_option (Dom_html.CoerceTo.element node) with
24
Added:
| None -> acc
25
Added:
| Some el -> el :: acc)
26
Added:
in
27
Added:
collect (i - 1) acc
28
Added:
in
29
Added:
collect (nodes##.length - 1) []
30
Added:
14
31
let closest selector element =
15
32
match Js.Opt.to_option (element##closest (Js.string selector)) with
16
33
| None -> None
@@ -30,11 +47,13 @@
30
47
| Some status -> status##.textContent := Js.some (Js.string message)
31
48
| None -> ()
32
49
33
Removed:
(* The workout timer. The sticky bar carries the workout's start time as an
34
Removed:
epoch in [data-started]. The elapsed time is computed from it, so a content
35
Removed:
swap that re-renders the bar never resets the count. A single interval runs
36
Removed:
at a time: [start_timer] clears the previous one before it reads the bar the
37
Removed:
latest render produced. When no bar is present, the timer stops. *)
50
Added:
(* The workout timer. The middle nav slot carries the workout's start time as an
51
Added:
epoch in [data-started] while logging. The elapsed time is computed from it,
52
Added:
so a content swap never resets the count. The mobile and desktop navs each
53
Added:
hold a timer, so every timer element is updated from its own [data-started]
54
Added:
and its own value span. A single interval runs at a time: [start_timer]
55
Added:
clears the previous one before it reads the timers the latest render
56
Added:
produced. When no timer is present, the timer stops. *)
38
57
let timer_interval = ref None
39
58
40
59
let format_elapsed seconds =
@@ -44,10 +63,12 @@
44
63
Printf.sprintf "%d:%02d" minutes secs
45
64
46
65
let tick () =
47
Removed:
match query_one document "[data-hito-workout-timer]" with
48
Removed:
| None -> ()
49
Removed:
| Some bar -> (
50
Removed:
match Js.Opt.to_option (bar##getAttribute (Js.string "data-started")) with
66
Added:
let now = Js.to_float (new%js Js.date_now)##getTime /. 1000. in
67
Added:
List.iter
68
Added:
(fun timer ->
69
Added:
match
70
Added:
Js.Opt.to_option (timer##getAttribute (Js.string "data-started"))
71
Added:
with
51
72
| None -> ()
52
73
| Some started -> (
53
74
(* Epoch seconds exceed js_of_ocaml's 31-bit [int] range, so the
@@ -58,13 +79,13 @@
58
79
try float_of_string (String.trim (Js.to_string started))
59
80
with _ -> 0.
60
81
in
61
Removed:
let now = Js.to_float (new%js Js.date_now)##getTime /. 1000. in
62
82
let elapsed = int_of_float (now -. started) in
63
Removed:
match query_one document "[data-hito-workout-timer-value]" with
83
Added:
match query_one timer "[data-hito-workout-timer-value]" with
64
84
| None -> ()
65
85
| Some value ->
66
86
value##.textContent :=
67
87
Js.some (Js.string (format_elapsed elapsed))))
88
Added:
(query_all document "[data-hito-workout-timer]")
68
89
69
90
let start_timer () =
70
91
(match !timer_interval with
test/web/workout.ml
@@ -486,6 +486,37 @@
486
486
"once every slot is recorded the group is complete" true
487
487
(Option.is_some
488
488
(index_from ~needle:"finish-actions-complete" page 0)) );
489
Added:
( "the elapsed timer shows in the nav on every page while logging",
490
Added:
`Quick,
491
Added:
fun () ->
492
Added:
(* While a workout is in progress the middle nav slot is the elapsed
493
Added:
timer, carrying its start epoch for the client to tick — on the
494
Added:
workout page and on every other page, as a running-workout
495
Added:
reminder. Off a workout, the middle slot is the routine link. *)
496
Added:
let c, now, _repo = clocked_client () in
497
Added:
now := day 10;
498
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
499
Added:
(* Before a workout, no page shows a nav timer. *)
500
Added:
let home = body (get c "/") in
501
Added:
Alcotest.(check bool)
502
Added:
"no nav timer before a workout" true
503
Added:
(Option.is_none (index_from ~needle:"nav-timer" home 0));
504
Added:
let _ = start_day_one c in
505
Added:
let page = body (get c "/workout") in
506
Added:
Alcotest.(check bool)
507
Added:
"the workout page shows the nav timer" true
508
Added:
(Option.is_some (index_from ~needle:"nav-timer" page 0));
509
Added:
Alcotest.(check bool)
510
Added:
"the timer carries its start epoch" true
511
Added:
(Option.is_some (index_from ~needle:"data-started" page 0));
512
Added:
(* The reminder shows away from the workout page too: the logbook. *)
513
Added:
let logbook = body (get c "/logbook") in
514
Added:
Alcotest.(check bool)
515
Added:
"the logbook shows the nav timer while logging" true
516
Added:
(Option.is_some (index_from ~needle:"nav-timer" logbook 0));
517
Added:
Alcotest.(check bool)
518
Added:
"the logbook nav timer carries its start epoch" true
519
Added:
(Option.is_some (index_from ~needle:"data-started" logbook 0)) );
489
520
( "the recovery gate blocks the next workout until enough rest, then \
490
521
Day 2 begins",
491
522
`Quick,