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.

Commit
83d38d61b7120fa3143173cf8b0a25a27d1d283d
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
.kiro/steering/workflow.md
index ad52a6bc..722b02e6 100644..100644
@@ -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
index b8dade46..5e266a1d 100644..100644
@@ -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
index f621d2e4..fbedcded 100644..100644
@@ -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) {