[OCaml] High Intensity Training Online
refactor structure workout logging controls
Keep each movement and its set ending as distinct semantic groups. Align value cells so the logging form stays stable across records and corrections. Clarify that web tests cover user affordances instead of DOM or styling details.
.kiro/steering/workflow.md
@@ -6,6 +6,10 @@
6
6
7
7
1. Alcotests exist for the feature. Write unit tests for behaviour. Write
8
8
end-to-end tests for flows that cross module boundaries.
9
Added:
- Web tests check the affordances that let a user complete an action and the
10
Added:
user-visible result of that action.
11
Added:
- Do not test the DOM structure, CSS, layout, or styling. Review visual
12
Added:
changes with rendered evidence.
9
13
2. The touched files are formatted with `ocamlformat --inplace`. Do not use
10
14
`dune fmt` (see Build directory safety).
11
15
3. The documentation agrees with the change.
lib/presenter/pages_workout.ml
@@ -20,10 +20,8 @@
20
20
option "static" "then a static hold";
21
21
]
22
22
23
Removed:
(* One movement: the exercise name sits in its own column, beside a load input
24
Removed:
and a reps input. The function returns its three fields directly, so the
25
Removed:
logging fieldset can place every movement in one shared grid. Each input
26
Removed:
group shows an optional prior value, the current input, and its unit. *)
23
Added:
(* One movement is one item in the prescribed sequence. Its name, load, and
24
Added:
reps stay together, so a responsive layout cannot mix two movements. *)
27
25
let movement_group ~name ~load_id ~load_field ~reps_id ~reps_field ?load_value
28
26
?reps_value ?previous_load ?previous_reps ~reps_label () =
29
27
let number_field ~input_id ~field ~value ~step_ ~label ~previous ~unit =
@@ -66,20 +64,18 @@
66
64
control;
67
65
]
68
66
in
69
Removed:
[
70
Removed:
tag "div"
71
Removed:
[ class_ "exercise-name" ]
72
Removed:
[ tag "span" [ class_ "exercise-name-label" ] [ txt "%s" name ] ];
73
Removed:
number_field ~input_id:load_id ~field:load_field ~value:load_value
74
Removed:
~step_:"0.5" ~label:"Load (kg)" ~previous:previous_load ~unit:"kg";
75
Removed:
number_field ~input_id:reps_id ~field:reps_field ~value:reps_value
76
Removed:
~step_:"1" ~label:reps_label ~previous:previous_reps ~unit:"reps";
77
Removed:
]
67
Added:
tag "li"
68
Added:
[ class_ "movement-group" ]
69
Added:
[
70
Added:
tag "h3" [ class_ "movement-name" ] [ txt "%s" name ];
71
Added:
number_field ~input_id:load_id ~field:load_field ~value:load_value
72
Added:
~step_:"0.5" ~label:"Load (kg)" ~previous:previous_load ~unit:"kg";
73
Added:
number_field ~input_id:reps_id ~field:reps_field ~value:reps_value
74
Added:
~step_:"1" ~label:reps_label ~previous:previous_reps ~unit:"reps";
75
Added:
]
78
76
79
Removed:
(* [movements] carries the record/correction field spec the controller built
80
Removed:
from the slot's prescription: one row for a single exercise, two for a
81
Removed:
pre-exhaust pair, each already pre-filled when correcting. [submit] names the
82
Removed:
action. Load and reps of each movement are laid out side by side. *)
77
Added:
(* A stimulus records one movement or a prescribed pre-exhaust sequence. Its
78
Added:
ending belongs to the whole stimulus, not to its final movement. *)
83
79
let form_for_stimulus request ~action_path ~slot
84
80
~(movements : View_model.Workout.movement list) ~selected_extension
85
81
~selected_extension_amount ?(enhanced = false) ?(submit = "Record")
@@ -93,97 +89,108 @@
93
89
?previous_load:m.previous_load ?previous_reps:m.previous_reps
94
90
~reps_label:m.reps_label ()
95
91
in
96
Removed:
let movement_groups = List.concat_map movement_of movements in
97
Removed:
tag "form"
98
Removed:
([ action_path; post_form ]
99
Removed:
@ (if enhanced then [ Dream_html.attr "data-hito-workout-form" ]
100
Removed:
else [ Dream_html.attr "data-hito-app-form" ])
101
Removed:
@ if correction then [ Dream_html.attr "data-hito-correction-form" ] else []
102
Removed:
)
103
Removed:
[
104
Removed:
Dream_html.csrf_tag request;
105
Removed:
tag "fieldset"
106
Removed:
[ class_ "logging-fieldset" ]
107
Removed:
(movement_groups
108
Removed:
@ [
109
Removed:
tag "div"
110
Removed:
[ class_ "field field-ending" ]
92
Added:
let set_ending =
93
Added:
tag "fieldset"
94
Added:
[ class_ "set-ending" ]
95
Added:
[
96
Added:
tag "legend" [] [ txt "Set ending" ];
97
Added:
tag "div"
98
Added:
[ class_ "field field-ending" ]
99
Added:
[
100
Added:
tag "label"
111
101
[
112
Removed:
tag "label"
113
Removed:
[
114
Removed:
Dream_html.string_attr "for" "%s" (field_id "extension");
115
Removed:
class_ "sr-only";
116
Removed:
]
117
Removed:
[ txt "Ending" ];
118
Removed:
extension_select ~selected ~input_id:(field_id "extension") ();
102
Added:
Dream_html.string_attr "for" "%s" (field_id "extension");
103
Added:
class_ "sr-only";
104
Added:
]
105
Added:
[ txt "Ending" ];
106
Added:
extension_select ~selected ~input_id:(field_id "extension") ();
107
Added:
];
108
Added:
tag "div"
109
Added:
[ class_ "field field-extension-amount" ]
110
Added:
[
111
Added:
tag "label"
112
Added:
[
113
Added:
Dream_html.string_attr "for" "%s" (field_id "extension_amount");
114
Added:
class_ "sr-only";
115
Added:
]
116
Added:
[
117
Added:
tag "span"
118
Added:
[ class_ "amount-label-reps" ]
119
Added:
[ txt "Extension reps" ];
120
Added:
tag "span" [ class_ "amount-label-sep" ] [ txt " / " ];
121
Added:
tag "span"
122
Added:
[ class_ "amount-label-seconds" ]
123
Added:
[ txt "Hold seconds" ];
119
124
];
120
125
tag "div"
121
Removed:
[ class_ "field field-extension-amount" ]
126
Added:
[ class_ "input-group" ]
122
127
[
123
Removed:
tag "label"
128
Added:
void "input"
124
129
[
125
Removed:
Dream_html.string_attr "for" "%s"
130
Added:
type_ "number";
131
Added:
name "extension_amount";
132
Added:
Dream_html.string_attr "id" "%s"
126
133
(field_id "extension_amount");
127
Removed:
class_ "sr-only";
128
Removed:
]
134
Added:
Dream_html.string_attr "min" "1";
135
Added:
step "1";
136
Added:
Dream_html.string_attr "value" "%s"
137
Added:
selected_extension_amount;
138
Added:
];
139
Added:
tag "span"
140
Added:
[ class_ "input-unit" ]
129
141
[
130
Removed:
tag "span"
131
Removed:
[ class_ "amount-label-reps" ]
132
Removed:
[ txt "Extension reps" ];
142
Added:
tag "span" [ class_ "amount-label-reps" ] [ txt "reps" ];
133
143
tag "span" [ class_ "amount-label-sep" ] [ txt " / " ];
134
144
tag "span"
135
145
[ class_ "amount-label-seconds" ]
136
Removed:
[ txt "Hold seconds" ];
146
Added:
[ txt "seconds" ];
137
147
];
138
Removed:
tag "div"
139
Removed:
[ class_ "input-group" ]
140
Removed:
[
141
Removed:
void "input"
142
Removed:
[
143
Removed:
type_ "number";
144
Removed:
name "extension_amount";
145
Removed:
Dream_html.string_attr "id" "%s"
146
Removed:
(field_id "extension_amount");
147
Removed:
Dream_html.string_attr "min" "1";
148
Removed:
step "1";
149
Removed:
Dream_html.string_attr "value" "%s"
150
Removed:
selected_extension_amount;
151
Removed:
];
152
Removed:
tag "span"
153
Removed:
[ class_ "input-unit" ]
154
Removed:
[
155
Removed:
tag "span" [ class_ "amount-label-reps" ] [ txt "reps" ];
156
Removed:
tag "span" [ class_ "amount-label-sep" ] [ txt " / " ];
157
Removed:
tag "span"
158
Removed:
[ class_ "amount-label-seconds" ]
159
Removed:
[ txt "seconds" ];
160
Removed:
];
161
Removed:
];
162
148
];
163
Removed:
]
164
Removed:
@
165
Removed:
if correction then
166
Removed:
[
167
Removed:
void "input"
168
Removed:
[
169
Removed:
type_ "submit";
170
Removed:
Dream_html.string_attr "value" "%s" submit;
171
Removed:
Dream_html.attr "data-hito-correction-action";
172
Removed:
];
173
Removed:
tag "button"
174
Removed:
[
175
Removed:
type_ "reset";
176
Removed:
class_ "secondary";
177
Removed:
Dream_html.attr "data-hito-correction-action";
178
Removed:
Dream_html.attr "data-hito-correction-reset";
179
Removed:
]
180
Removed:
[ txt "Reset" ];
181
Removed:
]
182
Removed:
else
183
Removed:
[
184
Removed:
void "input"
185
Removed:
[ type_ "submit"; Dream_html.string_attr "value" "%s" submit ];
186
Removed:
]);
149
Added:
];
150
Added:
]
151
Added:
in
152
Added:
let action_control =
153
Added:
if correction then
154
Added:
tag "div"
155
Added:
[ class_ "correction-actions" ]
156
Added:
[
157
Added:
void "input"
158
Added:
[
159
Added:
type_ "submit";
160
Added:
Dream_html.string_attr "value" "%s" submit;
161
Added:
Dream_html.attr "data-hito-correction-action";
162
Added:
];
163
Added:
tag "button"
164
Added:
[
165
Added:
type_ "reset";
166
Added:
class_ "secondary";
167
Added:
Dream_html.attr "data-hito-correction-reset";
168
Added:
]
169
Added:
[ txt "Reset" ];
170
Added:
]
171
Added:
else
172
Added:
void "input"
173
Added:
[
174
Added:
type_ "submit";
175
Added:
class_ "record-action";
176
Added:
Dream_html.string_attr "value" "%s" submit;
177
Added:
]
178
Added:
in
179
Added:
tag "form"
180
Added:
([ action_path; post_form ]
181
Added:
@ (if enhanced then [ Dream_html.attr "data-hito-workout-form" ]
182
Added:
else [ Dream_html.attr "data-hito-app-form" ])
183
Added:
@ if correction then [ Dream_html.attr "data-hito-correction-form" ] else []
184
Added:
)
185
Added:
[
186
Added:
Dream_html.csrf_tag request;
187
Added:
tag "div"
188
Added:
[ class_ "logging-form" ]
189
Added:
[
190
Added:
tag "ol" [ class_ "movement-list" ] (List.map movement_of movements);
191
Added:
set_ending;
192
Added:
action_control;
193
Added:
];
187
194
]
188
195
189
196
let workout request ~viewer ?workout_started ~record_id ~active_slot
lib/web/assets/hito.css
@@ -463,16 +463,14 @@
463
463
background: var(--blood);
464
464
}
465
465
466
Removed:
/* Correction controls are direct items in the logging fieldset. JavaScript
467
Removed:
hides them until the form becomes dirty. Without JavaScript, both controls
468
Removed:
remain available. */
469
Removed:
[data-hito-js="on"] form[data-hito-correction-form]
470
Removed:
[data-hito-correction-action] {
466
Added:
/* JavaScript hides correction actions until the form differs from the
467
Added:
saved record. Without JavaScript, both controls remain available. */
468
Added:
[data-hito-js="on"] form[data-hito-correction-form] .correction-actions {
471
469
display: none;
472
470
}
473
471
[data-hito-js="on"] form[data-hito-correction-form][data-hito-dirty]
474
Removed:
[data-hito-correction-action] {
475
Removed:
display: inline-flex;
472
Added:
.correction-actions {
473
Added:
display: grid;
476
474
}
477
475
478
476
/* Keeps the finish and cancel controls together as equal-width actions. */
@@ -784,22 +782,40 @@
784
782
.chart-series.weight { stroke: var(--blood); }
785
783
.chart-series.reps { stroke: var(--brass); stroke-dasharray: 5 3; }
786
784
787
Removed:
/* --- Workout logging fieldset ---
788
Removed:
Each movement is a three-column row: the exercise name, then the load
789
Removed:
input, then the reps input. Movements stack vertically with a gap between
790
Removed:
them. Each input group carries its own unit, so no separate heading row is
791
Removed:
needed. */
792
Removed:
.exercise-name {
785
Added:
/* --- Workout logging ---
786
Added:
A stimulus has an ordered list of movements and one set ending. Each
787
Added:
movement owns its three related controls, so a grid never mixes movements.
788
Added:
The ending is a sibling concept, not another movement row. */
789
Added:
.logging-form {
793
790
display: flex;
791
Added:
flex-direction: column;
792
Added:
gap: 1rem;
793
Added:
margin-top: 1rem;
794
Added:
}
795
Added:
.movement-list {
796
Added:
display: grid;
797
Added:
gap: 0.7rem;
798
Added:
margin: 0;
799
Added:
padding: 0;
800
Added:
list-style: none;
801
Added:
}
802
Added:
.movement-group {
803
Added:
display: grid;
804
Added:
grid-template-columns: repeat(3, minmax(0, 1fr));
794
805
align-items: center;
795
Removed:
min-height: 3rem;
806
Added:
gap: 0.6rem;
796
807
}
797
Removed:
.exercise-name-label {
798
Removed:
display: block;
808
Added:
.movement-name {
809
Added:
margin: 0;
799
810
color: var(--ink);
800
Removed:
font: 700 var(--font-size-lead)/1.2 var(--sans);
811
Added:
font: 600 var(--font-size-lead)/1.2 var(--sans);
801
812
letter-spacing: -0.01em;
802
813
}
814
Added:
.movement-group > .field,
815
Added:
.set-ending > .field {
816
Added:
min-width: 0;
817
Added:
margin: 0;
818
Added:
}
803
819
/* The record to beat and the unit are leading and trailing input addons. */
804
820
.exercise-previous,
805
821
.input-unit {
@@ -816,6 +832,8 @@
816
832
white-space: nowrap;
817
833
}
818
834
.exercise-previous {
835
Added:
min-inline-size: 4rem;
836
Added:
justify-content: center;
819
837
margin: 0;
820
838
border-right: 0;
821
839
border-radius: var(--radius) 0 0 var(--radius);
@@ -845,62 +863,71 @@
845
863
border-radius: var(--radius) 0 0 var(--radius);
846
864
}
847
865
.input-unit {
866
Added:
min-inline-size: 5.5rem;
867
Added:
justify-content: center;
848
868
border-left: 0;
849
869
border-radius: 0 var(--radius) var(--radius) 0;
850
870
}
851
Removed:
852
Removed:
.logging-fieldset {
871
Added:
/* The set ending is a property of the completed stimulus. The dividing rule
872
Added:
and legend make that distinction clear without a second card. */
873
Added:
.set-ending {
853
874
display: grid;
854
875
grid-template-columns: repeat(3, minmax(0, 1fr));
855
Removed:
align-items: start;
856
Removed:
column-gap: 0.6rem;
857
Removed:
row-gap: 1rem;
858
Removed:
margin-top: 1rem;
876
Added:
gap: 0.6rem;
877
Added:
margin: 0.1rem 0 0;
878
Added:
padding: 0.9rem 0 0;
879
Added:
border: 0;
880
Added:
border-top: 1px solid var(--rule);
859
881
}
860
Removed:
.logging-fieldset > .field { margin: 0; min-width: 0; }
861
Removed:
.logging-fieldset > .field-ending { grid-column: 1 / 3; }
862
Removed:
.logging-fieldset > .field-extension-amount { grid-column: 3; }
863
Removed:
.logging-fieldset > [data-hito-correction-action] {
864
Removed:
display: inline-flex;
865
Removed:
align-items: center;
866
Removed:
justify-content: center;
867
Removed:
width: 100%;
868
Removed:
margin-top: 0;
869
Removed:
}
870
Removed:
.logging-fieldset > input[type="submit"]:not([data-hito-correction-action]) {
882
Added:
.set-ending > legend {
871
883
grid-column: 1 / -1;
884
Added:
padding: 0;
885
Added:
color: var(--muted-ink);
886
Added:
font: 600 var(--font-size-small)/1.35 var(--sans);
887
Added:
letter-spacing: 0.05em;
888
Added:
text-transform: uppercase;
872
889
}
873
Removed:
form[data-hito-correction-form]
874
Removed:
.logging-fieldset > [data-hito-correction-action][type="submit"] {
875
Removed:
grid-column: 1 / span 2;
876
Removed:
align-self: stretch;
877
Removed:
margin: 0 !important;
890
Added:
.set-ending > .field-ending { grid-column: 1 / span 2; }
891
Added:
.set-ending > .field-extension-amount { grid-column: 3; }
892
Added:
.record-action { margin: 0 !important; }
893
Added:
.correction-actions {
894
Added:
display: grid;
895
Added:
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
896
Added:
gap: 0.6rem;
878
897
}
879
Removed:
form[data-hito-correction-form]
880
Removed:
.logging-fieldset > [data-hito-correction-action][type="reset"] {
881
Removed:
grid-column: 3;
882
Removed:
align-self: stretch;
883
Removed:
margin: 0 !important;
898
Added:
.correction-actions input[type="submit"],
899
Added:
.correction-actions button[type="reset"] {
900
Added:
width: 100%;
901
Added:
margin: 0;
884
902
}
903
Added:
.correction-actions input[type="submit"] {
904
Added:
color: var(--blood);
905
Added:
background: transparent;
906
Added:
border: 2px solid var(--blood-dark);
907
Added:
}
908
Added:
.correction-actions input[type="submit"]:hover {
909
Added:
color: var(--on-primary);
910
Added:
background: var(--blood);
911
Added:
}
885
912
886
913
/* The extension amount follows the live selection with no script, because
887
914
:has() reads the checked option. Positive failure hides the amount. A static
888
915
hold counts seconds. Every other ending counts reps. */
889
916
@supports selector(:has(*)) {
890
Removed:
.logging-fieldset .amount-label-sep { display: none; }
891
Removed:
.logging-fieldset:has(select[name="extension"] option[value=""]:checked)
917
Added:
.set-ending .amount-label-sep { display: none; }
918
Added:
.set-ending:has(select[name="extension"] option[value=""]:checked)
892
919
.field-extension-amount {
893
920
display: none;
894
921
}
895
Removed:
.logging-fieldset:has(select[name="extension"] option[value=""]:checked)
922
Added:
.set-ending:has(select[name="extension"] option[value=""]:checked)
896
923
.field-ending {
897
924
grid-column: 1 / -1;
898
925
}
899
Removed:
.logging-fieldset:has(select[name="extension"] option[value="static"]:checked)
926
Added:
.set-ending:has(select[name="extension"] option[value="static"]:checked)
900
927
.amount-label-reps {
901
928
display: none;
902
929
}
903
Removed:
.logging-fieldset:not(:has(select[name="extension"] option[value="static"]:checked))
930
Added:
.set-ending:not(:has(select[name="extension"] option[value="static"]:checked))
904
931
.amount-label-seconds {
905
932
display: none;
906
933
}
@@ -1193,26 +1220,21 @@
1193
1220
main {
1194
1221
min-height: calc(100vh - 9rem);
1195
1222
}
1196
Removed:
}
1197
1223
1198
Removed:
@media (max-width: 26rem) {
1199
Removed:
.button-group { grid-template-columns: 1fr; }
1200
Removed:
1201
Removed:
.logging-fieldset {
1224
Added:
/* One column: a movement and its ending each take the full width. */
1225
Added:
.movement-group,
1226
Added:
.set-ending,
1227
Added:
.correction-actions {
1202
1228
grid-template-columns: 1fr;
1203
1229
}
1204
Removed:
.logging-fieldset > .field-ending,
1205
Removed:
.logging-fieldset > .field-extension-amount {
1230
Added:
.set-ending > .field-ending,
1231
Added:
.set-ending > .field-extension-amount {
1206
1232
grid-column: 1;
1207
1233
}
1208
Removed:
form[data-hito-correction-form]
1209
Removed:
.logging-fieldset > [data-hito-correction-action][type="submit"] {
1210
Removed:
grid-column: 1;
1211
Removed:
}
1212
Removed:
form[data-hito-correction-form]
1213
Removed:
.logging-fieldset > [data-hito-correction-action][type="reset"] {
1214
Removed:
grid-column: 1;
1215
Removed:
}
1234
Added:
}
1235
Added:
1236
Added:
@media (max-width: 26rem) {
1237
Added:
.button-group { grid-template-columns: 1fr; }
1216
1238
}
1217
1239
1218
1240
@media (prefers-reduced-motion: reduce) {