[OCaml] High Intensity Training Online
feat select the exercise with a dropdown
Replace the vertical exercise button group with a dropdown. The selector is a GET form that submits the chosen slot to the workout path, so it works without script through the same ?slot= contract the panel already reads. The client enhances it: a change navigates immediately with a workout-scoped content swap, and the submit button is the no-JS fallback. A recorded slot is marked done in its option label.
Changed files
ENHANCEMENTS.org
@@ -44,7 +44,7 @@
44
44
Remove the thick black bars visible in the mobile bottom navigation.
45
45
** 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
Removed:
** TODO Replace the exercise button group with a dropdown
47
Added:
** DONE Replace the exercise button group with a dropdown
48
48
Use a dropdown menu to select the exercise instead of the exercise button group.
49
49
** TODO Remove the boulder top border from exercise logging cards
50
50
Do not show a boulder top fieldset border on the exercise logging fieldset card.
@@ -56,3 +56,5 @@
56
56
Animate page transitions between Home, Current Workout, and Logbook.
57
57
** TODO Confirm workout cancellation in a modal
58
58
Show a modal menu that confirms workout logging cancellation before cancelling.
59
Added:
** TODO Make workout logging fields compact on mobile
60
Added:
Use a grid layout so the exercise name, load input, and reps input appear on one row on mobile.
lib/web/assets/hito.css
@@ -311,67 +311,25 @@
311
311
.in-progress-card h1 { margin: 0.35rem 0; }
312
312
.in-progress-card p { margin: 0; }
313
313
314
Removed:
/* The exercise button group. Server-rendered links, one per prescription slot,
315
Removed:
carrying the slot in a query parameter. The active button reads as pressed; a
316
Removed:
recorded slot is marked with a check. Buttons stack vertically and fill the
317
Removed:
width, so a narrow screen never needs horizontal scrolling. */
318
Removed:
.exercise-group {
314
Added:
/* The exercise selector. A dropdown naming every prescription slot, in a GET
315
Added:
form that submits to the workout path. The client navigates on change, so the
316
Added:
submit button is a no-JS fallback. Select and button share one row and wrap
317
Added:
on a narrow screen. */
318
Added:
.exercise-select {
319
319
display: flex;
320
Removed:
flex-direction: column;
321
Removed:
gap: 0.4rem;
320
Added:
flex-wrap: wrap;
321
Added:
align-items: end;
322
Added:
gap: 0.6rem;
322
323
margin: 1.25rem 0 0;
323
324
}
324
Removed:
325
Removed:
.exercise-button {
326
Removed:
display: flex;
327
Removed:
align-items: center;
328
Removed:
gap: 0.6rem;
329
Removed:
width: 100%;
330
Removed:
min-height: 2.75rem;
331
Removed:
padding: 0.6rem 0.85rem;
332
Removed:
border: 1px solid var(--rule-strong);
333
Removed:
border-radius: var(--radius);
334
Removed:
color: var(--ink);
335
Removed:
background: var(--paper);
336
Removed:
font-size: 0.95rem;
337
Removed:
font-weight: 600;
338
Removed:
text-align: left;
339
Removed:
text-decoration: none;
340
Removed:
}
341
Removed:
342
Removed:
.exercise-button:hover {
343
Removed:
color: var(--oxblood-dark);
344
Removed:
background: #ece4d5;
345
Removed:
}
346
Removed:
347
Removed:
.exercise-button.is-active {
348
Removed:
color: var(--paper-raised);
349
Removed:
background: var(--oxblood);
350
Removed:
border-color: var(--oxblood-dark);
351
Removed:
}
352
Removed:
353
Removed:
.exercise-button-index {
354
Removed:
display: grid;
325
Added:
.exercise-select label { width: 100%; margin: 0 0 0.35rem; }
326
Added:
.exercise-select select { flex: 1 1 12rem; min-width: 0; margin: 0; }
327
Added:
.exercise-select .exercise-select-go {
355
328
flex: none;
356
Removed:
width: 1.5rem;
357
Removed:
height: 1.5rem;
358
Removed:
place-items: center;
359
Removed:
border: 1px solid currentColor;
360
Removed:
border-radius: 50%;
361
Removed:
font: 700 0.72rem/1 var(--mono);
362
Removed:
font-variant-numeric: tabular-nums;
363
Removed:
}
364
Removed:
365
Removed:
.exercise-button.is-done .exercise-button-index {
366
Removed:
border-color: var(--evergreen);
367
Removed:
color: var(--paper-raised);
368
Removed:
background: var(--evergreen);
369
Removed:
}
370
Removed:
371
Removed:
.exercise-button.is-active.is-done .exercise-button-index {
372
Removed:
border-color: var(--paper-raised);
373
Removed:
color: var(--oxblood);
374
Removed:
background: var(--paper-raised);
329
Added:
width: auto;
330
Added:
min-height: 3rem;
331
Added:
margin: 0;
332
Added:
padding: 0.65rem 1.1rem;
375
333
}
376
334
377
335
/* The sticky workout timer bar. It pins to the top of the scroll area while a
lib/web/pages.ml
@@ -631,18 +631,6 @@
631
631
Printf.sprintf "%s + %s" (Exercise.name isolation)
632
632
(Exercise.name compound)
633
633
634
Removed:
(* The base path a slot's button links to, without the [?slot=] the exercise
635
Removed:
group appends. Current-workout buttons return to [/workout]; a saved record's
636
Removed:
buttons return to [/logbook/<id>]. The URL is built directly so the [slot]
637
Removed:
query can be appended. *)
638
Removed:
let exercise_href ~record_id slot =
639
Removed:
let base =
640
Removed:
match record_id with
641
Removed:
| None -> "/workout"
642
Removed:
| Some record_id -> "/logbook/" ^ record_id
643
Removed:
in
644
Removed:
Printf.sprintf "%s?slot=%d" base slot
645
Removed:
646
634
let workout request ~trainee ?(errors = []) ?editing ?logging ~record_id
647
635
~active_slot workout =
648
636
let enhanced = Option.is_none record_id in
@@ -683,55 +671,58 @@
683
671
in
684
672
let recorded_at slot = List.assoc_opt slot filled in
685
673
let is_recorded slot = Option.is_some (recorded_at slot) in
686
Removed:
(* The exercise button group: one full-width button per prescription slot, a
687
Removed:
server-rendered link carrying the slot as a query parameter. The active
688
Removed:
button opens the panel below; a recorded slot is marked done. This is link
689
Removed:
navigation, not an ARIA widget: a labelled [nav] with [aria-current="page"]
690
Removed:
on the active link is the accessible, deterministic pattern — no client
691
Removed:
script, no focus state to manage. Buttons stack vertically, so a narrow
692
Removed:
screen never needs horizontal scrolling. *)
674
Added:
(* The exercise selector: a dropdown naming every prescription slot. Choosing
675
Added:
an option opens that exercise below. The selector is a GET form so it works
676
Added:
without script — submitting navigates to the slot via a [?slot=] query, the
677
Added:
same contract the panel already reads. The client enhances it: it navigates
678
Added:
the moment the selection changes, so the submit button is a no-JS fallback.
679
Added:
A recorded slot is marked done in its label. *)
680
Added:
let select_action_base =
681
Added:
match record_id with None -> "/workout" | Some id -> "/logbook/" ^ id
682
Added:
in
693
683
let exercise_group =
694
Removed:
tag "nav"
684
Added:
let option_for slot p =
685
Added:
let done_ = is_recorded slot in
686
Added:
let label =
687
Added:
if done_ then
688
Added:
Printf.sprintf "%d. %s (done)" (slot + 1) (exercise_label p)
689
Added:
else Printf.sprintf "%d. %s" (slot + 1) (exercise_label p)
690
Added:
in
691
Added:
let attrs = [ Dream_html.string_attr "value" "%d" slot ] in
692
Added:
let attrs =
693
Added:
if slot = active_slot then Dream_html.attr "selected" :: attrs
694
Added:
else attrs
695
Added:
in
696
Added:
tag "option" attrs [ txt "%s" label ]
697
Added:
in
698
Added:
let form_attrs =
695
699
[
696
Removed:
class_ "exercise-group"; Dream_html.string_attr "aria-label" "Exercises";
700
Added:
Dream_html.string_attr "method" "get";
701
Added:
Dream_html.string_attr "action" "%s" select_action_base;
702
Added:
class_ "exercise-select";
697
703
]
698
Removed:
(List.mapi
699
Removed:
(fun slot p ->
700
Removed:
let current = slot = active_slot in
701
Removed:
let done_ = is_recorded slot in
702
Removed:
let state =
703
Removed:
match (current, done_) with
704
Removed:
| true, true -> "exercise-button is-active is-done"
705
Removed:
| true, false -> "exercise-button is-active"
706
Removed:
| false, true -> "exercise-button is-done"
707
Removed:
| false, false -> "exercise-button"
708
Removed:
in
709
Removed:
let attrs =
710
Removed:
[
711
Removed:
Dream_html.string_attr "class" "%s" state;
712
Removed:
Dream_html.string_attr "href" "%s"
713
Removed:
(exercise_href ~record_id slot);
714
Removed:
]
715
Removed:
in
716
Removed:
let attrs =
717
Removed:
if enhanced then Dream_html.attr "data-hito-workout-link" :: attrs
718
Removed:
else attrs
719
Removed:
in
720
Removed:
let attrs =
721
Removed:
if current then
722
Removed:
Dream_html.string_attr "aria-current" "page" :: attrs
723
Removed:
else attrs
724
Removed:
in
725
Removed:
tag "a" attrs
726
Removed:
[
727
Removed:
tag "span"
728
Removed:
[ class_ "exercise-button-index" ]
729
Removed:
[ txt "%d" (slot + 1) ];
730
Removed:
tag "span"
731
Removed:
[ class_ "exercise-button-name" ]
732
Removed:
[ txt "%s" (exercise_label p) ];
733
Removed:
])
734
Removed:
prescribed_slots)
704
Added:
in
705
Added:
let form_attrs =
706
Added:
if enhanced then Dream_html.attr "data-hito-exercise-form" :: form_attrs
707
Added:
else form_attrs
708
Added:
in
709
Added:
tag "form" form_attrs
710
Added:
[
711
Added:
tag "label"
712
Added:
[ Dream_html.string_attr "for" "exercise-choice" ]
713
Added:
[ txt "Exercise" ];
714
Added:
tag "select"
715
Added:
[
716
Added:
name "slot";
717
Added:
id "exercise-choice";
718
Added:
Dream_html.attr "data-hito-exercise-select";
719
Added:
]
720
Added:
(List.mapi option_for prescribed_slots);
721
Added:
void "input"
722
Added:
[
723
Added:
type_ "submit"; class_ "secondary exercise-select-go"; value "Open";
724
Added:
];
725
Added:
]
735
726
in
736
727
(* The panel for the active slot. It shows one exercise at a time: a recorded
737
728
slot renders its read-only summary and a pre-filled correction form (which
lib/web/workout_client.ml
@@ -229,6 +229,30 @@
229
229
Lwt.async (fun () -> get (Js.to_string Dom_html.window##.location##.href));
230
230
Js._true
231
231
232
Added:
(* The exercise dropdown navigates the moment its selection changes. It lives in
233
Added:
a GET form ([data-hito-exercise-form]) that submits to the workout path; the
234
Added:
client turns a change into a workout-scoped content swap to [?slot=N], so no
235
Added:
submit click is needed. Without script the form's submit button still works. *)
236
Added:
let exercise_change event =
237
Added:
match closest "[data-hito-exercise-form]" (Dom_html.eventTarget event) with
238
Added:
| None -> Js._true
239
Added:
| Some form -> (
240
Added:
let action =
241
Added:
match Js.Opt.to_option (form##getAttribute (Js.string "action")) with
242
Added:
| Some action -> Js.to_string action
243
Added:
| None -> "/workout"
244
Added:
in
245
Added:
match query_one form "[data-hito-exercise-select]" with
246
Added:
| None -> Js._true
247
Added:
| Some node -> (
248
Added:
match Js.Opt.to_option (Dom_html.CoerceTo.select node) with
249
Added:
| None -> Js._true
250
Added:
| Some select ->
251
Added:
let slot = Js.to_string select##.value in
252
Added:
Dom.preventDefault event;
253
Added:
Lwt.async (fun () -> get ~workout:true (action ^ "?slot=" ^ slot));
254
Added:
Js._false))
255
Added:
232
256
let () =
233
257
Dom_html.addEventListener document Dom_html.Event.click
234
258
(Dom_html.handler link_click)
@@ -236,6 +260,10 @@
236
260
|> ignore;
237
261
Dom_html.addEventListener document Dom_html.Event.submit
238
262
(Dom_html.handler form_submit)
263
Added:
Js._false
264
Added:
|> ignore;
265
Added:
Dom_html.addEventListener document Dom_html.Event.change
266
Added:
(Dom_html.handler exercise_change)
239
267
Js._false
240
268
|> ignore;
241
269
Dom_html.addEventListener Dom_html.window Dom_html.Event.popstate
test/test_web.ml
@@ -433,21 +433,24 @@
433
433
"renders Day 1 as the page heading" true
434
434
(contains ~substring:"<h1>Day 1</h1>" workout_page);
435
435
Alcotest.(check bool)
436
Removed:
"renders a vertical exercise button group" true
437
Removed:
(contains ~substring:"exercise-group" workout_page);
436
Added:
"renders an exercise dropdown selector" true
437
Added:
(contains ~substring:"exercise-select" workout_page);
438
438
Alcotest.(check bool)
439
439
"does not use the old tab strip class" false
440
440
(contains ~substring:"slot-tab" workout_page);
441
441
Alcotest.(check bool)
442
Removed:
"labels the exercise group for assistive tech" true
443
Removed:
(contains ~substring:"aria-label=\"Exercises\"" workout_page);
442
Added:
"does not use the old button group class" false
443
Added:
(contains ~substring:"exercise-group" workout_page);
444
444
Alcotest.(check bool)
445
Removed:
"marks the active button with aria-current" true
446
Removed:
(contains ~substring:"aria-current=\"page\"" workout_page);
445
Added:
"marks the exercise select for enhancement" true
446
Added:
(contains ~substring:"data-hito-exercise-select" workout_page);
447
447
Alcotest.(check bool)
448
Removed:
"links a later slot by query parameter" true
449
Removed:
(contains ~substring:"?slot=1" workout_page);
448
Added:
"preselects the active slot option" true
449
Added:
(contains ~substring:"selected" workout_page);
450
450
Alcotest.(check bool)
451
Added:
"offers a later slot as a select option" true
452
Added:
(contains ~substring:"value=\"1\"" workout_page);
453
Added:
Alcotest.(check bool)
451
454
"opens on the first slot's record form" true
452
455
(contains ~substring:"/workout/slots/0" workout_page);
453
456
Alcotest.(check bool)
@@ -457,8 +460,8 @@
457
460
"marks the replaceable workout content" true
458
461
(contains ~substring:"data-hito-workout-content" workout_page);
459
462
Alcotest.(check bool)
460
Removed:
"marks exercise links for enhancement" true
461
Removed:
(contains ~substring:"data-hito-workout-link" workout_page);
463
Added:
"marks the exercise form for enhancement" true
464
Added:
(contains ~substring:"data-hito-exercise-form" workout_page);
462
465
Alcotest.(check bool)
463
466
"marks record forms while retaining their CSRF field" true
464
467
(contains ~substring:"data-hito-workout-form" workout_page
@@ -616,15 +619,14 @@
616
619
reached through the same [?slot=] state. *)
617
620
let record_page = body (get c "/logbook/w1?slot=1") in
618
621
Alcotest.(check bool)
619
Removed:
"renders a vertical exercise button group for the saved workout"
620
Removed:
true
621
Removed:
(contains ~substring:"exercise-group" record_page);
622
Added:
"renders an exercise dropdown for the saved workout" true
623
Added:
(contains ~substring:"exercise-select" record_page);
622
624
Alcotest.(check bool)
623
625
"does not use the old tab strip class" false
624
626
(contains ~substring:"slot-tab" record_page);
625
627
Alcotest.(check bool)
626
Removed:
"does not enhance saved workout links" false
627
Removed:
(contains ~substring:"data-hito-workout-link" record_page);
628
Added:
"does not enhance the saved exercise selector" false
629
Added:
(contains ~substring:"data-hito-exercise-form" record_page);
628
630
Alcotest.(check bool)
629
631
"does not mark saved history as replaceable workout content" false
630
632
(contains ~substring:"data-hito-workout-content" record_page);