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.

Commit
54d2a8c8273ec17087a1c95416969fa1d1c98853
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/page_shell.ml
index 387a5da7..a9dc29d7 100644..100644
@@ -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
index db5f655a..a82b823b 100644..100644
@@ -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
index ec88a4c2..bc62732d 100644..100644
@@ -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
index 8a735f64..f880aaef 100644..100644
@@ -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
index d87781d9..ed5e8f8b 100644..100644
@@ -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
index e87fc5c9..cfc8f130 100644..100644
@@ -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
index 47f1f42e..1d1de275 100644..100644
@@ -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
index 42393f53..0d70913d 100644..100644
@@ -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
index 7ee43c5a..9107fa8b 100644..100644
@@ -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
index 6fcbb095..ad50ccfa 100644..100644
@@ -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
index 092849a6..87daf40c 100644..100644
@@ -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
index c87e7aac..3d55d5b2 100644..100644
@@ -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
index b1705aac..b8671fe6 100644..100644
@@ -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
index 4235335e..23d1b9f3 100644..100644
@@ -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
index 15faf786..6703052a 100644..100644
@@ -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
index d3d66c3c..8ef042b4 100644..100644
@@ -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
index 66208452..66fa32eb 100644..100644
@@ -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
index 2037d560..6409c3df 100644..100644
@@ -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,