[OCaml] High Intensity Training Online
feat add a sticky workout timer bar
The live workout view now pins a timer bar to the top of the scroll area. The bar carries the workout's start time as an epoch; the client ticks the elapsed time from it every second. Reading the true start on every render means an SPA content swap never resets the count. Only the workout in progress shows the bar; saved records do not.
Changed files
ENHANCEMENTS.org
@@ -42,7 +42,7 @@
42
42
When cancelling a workout, always leave the current workout view without showing an invalid-form response. Save the workout log as-is for later editing, then redirect the user to Home.
43
43
** DONE Remove thick black bars from mobile bottom navigation
44
44
Remove the thick black bars visible in the mobile bottom navigation.
45
Removed:
** TODO Add a sticky workout timer bar
45
Added:
** DONE Add a sticky workout timer bar
46
46
When logging a workout, show a sticky bar at the top of the screen with a workout timer.
47
47
** TODO Replace the exercise button group with a dropdown
48
48
Use a dropdown menu to select the exercise instead of the exercise button group.
@@ -50,3 +50,9 @@
50
50
Do not show a boulder top fieldset border on the exercise logging fieldset card.
51
51
** TODO Increase bottom-navigation button text size
52
52
Increase the bottom-navigation button text font size to better support WCAG-aligned readability.
53
Added:
** TODO Harmonize keyboard-focus halo styling
54
Added:
Change the WCAG keyboard-navigation focus halo color so it follows the general UI theme and is less visually obtrusive.
55
Added:
** TODO Animate page transitions
56
Added:
Animate page transitions between Home, Current Workout, and Logbook.
57
Added:
** TODO Confirm workout cancellation in a modal
58
Added:
Show a modal menu that confirms workout logging cancellation before cancelling.
lib/web/assets/hito.css
@@ -374,6 +374,37 @@
374
374
background: var(--paper-raised);
375
375
}
376
376
377
Added:
/* The sticky workout timer bar. It pins to the top of the scroll area while a
378
Added:
workout is being logged and shows the elapsed time. The client ticks the
379
Added:
value from the workout's start time; server-rendered markup shows a static
380
Added:
start so the bar is present without script. */
381
Added:
.workout-timer {
382
Added:
position: sticky;
383
Added:
z-index: 10;
384
Added:
top: 0;
385
Added:
display: flex;
386
Added:
align-items: baseline;
387
Added:
justify-content: space-between;
388
Added:
gap: 0.75rem;
389
Added:
margin: 0 0 1rem;
390
Added:
border: 1px solid var(--rule-strong);
391
Added:
border-radius: var(--radius);
392
Added:
background: var(--paper-raised);
393
Added:
box-shadow: 0 0.2rem 0.6rem rgba(26, 26, 22, 0.08);
394
Added:
padding: 0.55rem 0.9rem;
395
Added:
}
396
Added:
.workout-timer-label {
397
Added:
color: var(--muted-ink);
398
Added:
font: 700 var(--font-size-small)/1 var(--sans);
399
Added:
letter-spacing: 0.08em;
400
Added:
text-transform: uppercase;
401
Added:
}
402
Added:
.workout-timer-value {
403
Added:
color: var(--ink);
404
Added:
font: 700 var(--font-size-lead)/1 var(--mono);
405
Added:
font-variant-numeric: tabular-nums;
406
Added:
}
407
Added:
377
408
.slot-panel { margin-top: 1.25rem; }
378
409
379
410
.recorded-slot { margin: 1rem 0; }
lib/web/pages.ml
@@ -778,6 +778,35 @@
778
778
]
779
779
else []
780
780
in
781
Added:
(* A sticky timer bar for the workout in progress. It carries the workout's
782
Added:
start time as an epoch, and the client ticks the elapsed time from it.
783
Added:
Reading the true start on every render means an SPA content swap never
784
Added:
resets the count. Only the live workout shows it. *)
785
Added:
let timer_bar =
786
Added:
if enhanced then
787
Added:
let started =
788
Added:
Recovery.timestamp_to_unix_seconds (Evidence.Workout.started_at workout)
789
Added:
in
790
Added:
[
791
Added:
tag "div"
792
Added:
[
793
Added:
class_ "workout-timer";
794
Added:
Dream_html.attr "data-hito-workout-timer";
795
Added:
Dream_html.string_attr "data-started" "%d" started;
796
Added:
]
797
Added:
[
798
Added:
tag "span" [ class_ "workout-timer-label" ] [ txt "Elapsed" ];
799
Added:
tag "span"
800
Added:
[
801
Added:
class_ "workout-timer-value";
802
Added:
Dream_html.string_attr "aria-live" "off";
803
Added:
Dream_html.attr "data-hito-workout-timer-value";
804
Added:
]
805
Added:
[ txt "0:00" ];
806
Added:
];
807
Added:
]
808
Added:
else []
809
Added:
in
781
810
let finish_section =
782
811
match record_id with
783
812
| Some _ -> []
@@ -822,7 +851,7 @@
822
851
Dream_html.attr "data-hito-workout-content";
823
852
]
824
853
else [])
825
Removed:
(override_note
854
Added:
(timer_bar @ override_note
826
855
@ [
827
856
tag "h1" [] [ txt "%s" (Prescription.Workout.name prescription) ];
828
857
tag "p"
lib/web/workout_client.ml
@@ -30,6 +30,54 @@
30
30
| Some status -> status##.textContent := Js.some (Js.string message)
31
31
| None -> ()
32
32
33
Added:
(* The workout timer. The sticky bar carries the workout's start time as an
34
Added:
epoch in [data-started]; the elapsed time is computed from it, so a content
35
Added:
swap that re-renders the bar never resets the count. A single interval runs
36
Added:
at a time: [start_timer] clears the previous one before it reads the bar the
37
Added:
latest render produced. When no bar is present, the timer stops. *)
38
Added:
let timer_interval = ref None
39
Added:
40
Added:
let format_elapsed seconds =
41
Added:
let seconds = if seconds < 0 then 0 else seconds in
42
Added:
let minutes = seconds / 60 in
43
Added:
let secs = seconds mod 60 in
44
Added:
Printf.sprintf "%d:%02d" minutes secs
45
Added:
46
Added:
let tick () =
47
Added:
match query_one document "[data-hito-workout-timer]" with
48
Added:
| None -> ()
49
Added:
| Some bar -> (
50
Added:
match Js.Opt.to_option (bar##getAttribute (Js.string "data-started")) with
51
Added:
| None -> ()
52
Added:
| Some started -> (
53
Added:
let started =
54
Added:
try int_of_string (String.trim (Js.to_string started)) with _ -> 0
55
Added:
in
56
Added:
let now = int_of_float (Js.to_float (new%js Js.date_now)##getTime) in
57
Added:
let elapsed = (now / 1000) - started in
58
Added:
match query_one document "[data-hito-workout-timer-value]" with
59
Added:
| None -> ()
60
Added:
| Some value ->
61
Added:
value##.textContent :=
62
Added:
Js.some (Js.string (format_elapsed elapsed))))
63
Added:
64
Added:
let start_timer () =
65
Added:
(match !timer_interval with
66
Added:
| Some id ->
67
Added:
Dom_html.window##clearInterval id;
68
Added:
timer_interval := None
69
Added:
| None -> ());
70
Added:
match query_one document "[data-hito-workout-timer]" with
71
Added:
| None -> ()
72
Added:
| Some _ ->
73
Added:
tick ();
74
Added:
let id =
75
Added:
Dom_html.window##setInterval
76
Added:
(Js.wrap_callback (fun () -> tick ()))
77
Added:
(Js.number_of_float 1000.)
78
Added:
in
79
Added:
timer_interval := Some id
80
Added:
33
81
let replace selector html =
34
82
let next = Dom_html.createDiv document in
35
83
next##.innerHTML := Js.string html;
@@ -49,6 +97,7 @@
49
97
heading##focus
50
98
| None -> ());
51
99
announce "Page updated";
100
Added:
start_timer ();
52
101
true
53
102
| _ -> false
54
103
@@ -192,4 +241,5 @@
192
241
Dom_html.addEventListener Dom_html.window Dom_html.Event.popstate
193
242
(Dom_html.handler popstate)
194
243
Js._false
195
Removed:
|> ignore
244
Added:
|> ignore;
245
Added:
start_timer ()
test/test_web.ml
@@ -769,6 +769,24 @@
769
769
Alcotest.(check bool)
770
770
"renders it as a secondary submit" true
771
771
(contains ~substring:"class=\"secondary\"" page) );
772
Added:
( "the current workout shows a sticky timer bar",
773
Added:
`Quick,
774
Added:
fun () ->
775
Added:
let c = client () in
776
Added:
let _ = sign_in_new c in
777
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
778
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
779
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
780
Added:
let _ =
781
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
782
Added:
in
783
Added:
let page = body (get c "/workout") in
784
Added:
Alcotest.(check bool)
785
Added:
"renders the timer bar while logging" true
786
Added:
(contains ~substring:"data-hito-workout-timer" page);
787
Added:
Alcotest.(check bool)
788
Added:
"carries the workout start epoch" true
789
Added:
(contains ~substring:"data-started=" page) );
772
790
( "cancelling a workout without a CSRF token leaves the view cleanly",
773
791
`Quick,
774
792
fun () ->