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.

Commit
df2ede74217a3c5dee1134305f9967115c803ac8
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ENHANCEMENTS.org
index 67aa26e8..81bda557 100644..100644
@@ -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
index c9b7ea71..14886d4b 100644..100644
@@ -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
index 3dd02ea0..b1ee5e99 100644..100644
@@ -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
index ade74a98..d6eb0422 100644..100644
@@ -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
index f67ae962..4db8e497 100644..100644
@@ -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 () ->