[OCaml] High Intensity Training Online
1
open Dream_html
2
open Page_kit
3
open Page_shell
4
5
let extension_select ?(selected = "") ~input_id () =
6
let option (code : string) label =
7
let attrs = [ Dream_html.string_attr "value" "%s" code ] in
8
let attrs =
9
if String.equal code selected then Dream_html.attr "selected" :: attrs
10
else attrs
11
in
12
tag "option" attrs [ txt "%s" label ]
13
in
14
tag "select"
15
[ name "extension"; Dream_html.string_attr "id" "%s" input_id ]
16
[
17
option "" "to positive failure";
18
option "forced" "then forced reps";
19
option "negatives" "then negatives";
20
option "static" "then a static hold";
21
]
22
23
(* The extension amount counts reps or seconds. Both words are rendered, and
24
the CSS shows the one that matches the selected ending. *)
25
let reps_or_seconds ~reps ~seconds =
26
[
27
tag "span" [ class_ "amount-label-reps" ] [ txt "%s" reps ];
28
tag "span" [ class_ "amount-label-sep" ] [ txt " / " ];
29
tag "span" [ class_ "amount-label-seconds" ] [ txt "%s" seconds ];
30
]
31
32
(* One movement is one item in the prescribed sequence. Its name, load, and
33
reps stay together, so a responsive layout cannot mix two movements. *)
34
let movement_group ~name ~load_id ~load_field ~reps_id ~reps_field ?load_value
35
?reps_value ?previous_load ?previous_reps ~reps_label () =
36
let number_field ~additional_class ~input_id ~field ~value ~step_ ~label
37
~previous ~unit =
38
let input =
39
void "input"
40
([
41
type_ "number";
42
Dream_html.string_attr "name" "%s" field;
43
Dream_html.string_attr "id" "%s" input_id;
44
step "%s" step_;
45
required;
46
]
47
@
48
match value with
49
| None -> []
50
| Some v -> [ Dream_html.string_attr "value" "%s" v ])
51
in
52
let group_class =
53
match previous with
54
| None -> "input-group"
55
| Some _ -> "input-group input-group-with-previous"
56
in
57
let previous_control =
58
match previous with
59
| None -> []
60
| Some p -> [ tag "span" [ class_ "exercise-previous" ] [ txt "%s" p ] ]
61
in
62
let control =
63
tag "div"
64
[ Dream_html.string_attr "class" "%s" group_class ]
65
(previous_control
66
@ [ input; tag "span" [ class_ "input-unit" ] [ txt "%s" unit ] ])
67
in
68
tag "div"
69
[ class_ "field field-number%s" additional_class ]
70
[
71
tag "label"
72
[ Dream_html.string_attr "for" "%s" input_id; class_ "sr-only" ]
73
[ txt "%s" label ];
74
control;
75
]
76
in
77
tag "li"
78
[ class_ "movement-group" ]
79
[
80
tag "fieldset"
81
[ class_ "movement-fieldset" ]
82
[
83
tag "legend" [] [ txt "%s" name ];
84
tag "div"
85
[ class_ "movement-controls" ]
86
[
87
number_field ~additional_class:"" ~input_id:load_id
88
~field:load_field ~value:load_value ~step_:"0.5"
89
~label:"Load (kg)" ~previous:previous_load ~unit:"kg";
90
number_field ~additional_class:" field-after-load"
91
~input_id:reps_id ~field:reps_field ~value:reps_value ~step_:"1"
92
~label:reps_label ~previous:previous_reps ~unit:"reps";
93
];
94
];
95
]
96
97
(* A stimulus records one movement or a prescribed pre-exhaust sequence. Its
98
ending belongs to the whole stimulus, not to its final movement. *)
99
let form_for_stimulus request ~action_path ~slot
100
~(movements : View_model.Workout.movement list) ~selected_extension
101
~selected_extension_amount ?(enhanced = false) ?(submit = "Record")
102
?(correction = false) () =
103
let field_id field = Printf.sprintf "slot-%d-%s" slot field in
104
let selected = selected_extension in
105
let movement_of (m : View_model.Workout.movement) =
106
movement_group ~name:m.name ~load_id:(field_id m.load_field)
107
~load_field:m.load_field ~reps_id:(field_id m.reps_field)
108
~reps_field:m.reps_field ?load_value:m.load_value ?reps_value:m.reps_value
109
?previous_load:m.previous_load ?previous_reps:m.previous_reps
110
~reps_label:m.reps_label ()
111
in
112
let set_ending =
113
tag "fieldset"
114
[ class_ "set-ending" ]
115
[
116
tag "legend" [] [ txt "Set ending" ];
117
tag "div"
118
[ class_ "set-ending-controls" ]
119
[
120
tag "div"
121
[ class_ "field field-ending" ]
122
[
123
tag "label"
124
[
125
Dream_html.string_attr "for" "%s" (field_id "extension");
126
class_ "sr-only";
127
]
128
[ txt "Ending" ];
129
extension_select ~selected ~input_id:(field_id "extension") ();
130
];
131
tag "div"
132
[ class_ "field field-extension-amount field-after-ending" ]
133
[
134
tag "label"
135
[
136
Dream_html.string_attr "for" "%s"
137
(field_id "extension_amount");
138
class_ "sr-only";
139
]
140
(reps_or_seconds ~reps:"Extension reps"
141
~seconds:"Hold seconds");
142
tag "div"
143
[ class_ "input-group" ]
144
[
145
void "input"
146
[
147
type_ "number";
148
name "extension_amount";
149
Dream_html.string_attr "id" "%s"
150
(field_id "extension_amount");
151
Dream_html.string_attr "min" "1";
152
step "1";
153
Dream_html.string_attr "value" "%s"
154
selected_extension_amount;
155
];
156
tag "span"
157
[ class_ "input-unit" ]
158
(reps_or_seconds ~reps:"reps" ~seconds:"seconds");
159
];
160
];
161
];
162
]
163
in
164
let action_control =
165
if correction then
166
tag "div"
167
[ class_ "correction-actions" ]
168
[
169
void "input"
170
[
171
type_ "submit";
172
Dream_html.string_attr "value" "%s" submit;
173
Dream_html.attr "data-hito-correction-action";
174
];
175
tag "button" [ type_ "reset"; class_ "secondary" ] [ txt "Reset" ];
176
]
177
else
178
void "input"
179
[
180
type_ "submit";
181
class_ "record-action";
182
Dream_html.string_attr "value" "%s" submit;
183
]
184
in
185
tag "form"
186
([ action_path; post_form ]
187
@ (if enhanced then [ Dream_html.attr "data-hito-workout-form" ]
188
else [ Dream_html.attr "data-hito-app-form" ])
189
@ if correction then [ Dream_html.attr "data-hito-correction-form" ] else []
190
)
191
[
192
Dream_html.csrf_tag request;
193
tag "div"
194
[ class_ "logging-form" ]
195
[
196
tag "ol" [ class_ "movement-list" ] (List.map movement_of movements);
197
set_ending;
198
action_control;
199
];
200
]
201
202
let workout request ~viewer ?workout_started ~record_id ~active_slot
203
(vm : View_model.Workout.t) =
204
let enhanced = Option.is_none record_id in
205
(* A live workout view is itself the workout in progress, so its own start
206
drives the nav timer. A saved-record view is history: the timer, if any,
207
belongs to a separate workout the handler reports through [workout_started]. *)
208
let workout_started =
209
if enhanced then Some vm.started_at_unix else workout_started
210
in
211
let record_action slot =
212
match record_id with
213
| None -> action Routes.workout_slot slot
214
| Some record_id -> action Routes.record_slot record_id slot
215
in
216
let edit_action slot =
217
match record_id with
218
| None -> action Routes.workout_slot_edit slot
219
| Some record_id -> action Routes.record_slot_edit record_id slot
220
in
221
(* The exercise accordion: one <details> per prescription slot, all sharing a
222
[name], which the browser treats as an exclusive group — opening one closes
223
the rest, with no script. The active slot is open; the handler picks it from
224
[?slot=], so a record or correction reopens the slot it belongs to. The
225
summary carries the slot's level-two heading: its number and exercise label,
226
struck through once recorded. A recorded slot shows a pre-filled correction
227
form (which replaces the slot rather than adding volume); an outstanding
228
slot shows the record form. *)
229
let slot_section (slot : View_model.Workout.slot) =
230
let index = slot.index in
231
let done_ = Option.is_some slot.recorded_summary in
232
let body =
233
match slot.recorded_summary with
234
| Some _ ->
235
[
236
form_for_stimulus request ~action_path:(edit_action index)
237
~slot:index ~movements:slot.movements
238
~selected_extension:slot.selected_extension
239
~selected_extension_amount:slot.selected_extension_amount
240
~enhanced ~submit:"Save correction" ~correction:true ();
241
]
242
| None ->
243
[
244
form_for_stimulus request ~action_path:(record_action index)
245
~slot:index ~movements:slot.movements
246
~selected_extension:slot.selected_extension
247
~selected_extension_amount:slot.selected_extension_amount
248
~enhanced ();
249
]
250
in
251
tag "details"
252
([
253
class_ "slot-accordion";
254
Dream_html.string_attr "name" "exercise";
255
Dream_html.string_attr "id" "slot-%d" index;
256
]
257
@ (if index = active_slot then [ Dream_html.attr "open" ] else [])
258
@ if done_ then [ Dream_html.string_attr "data-done" "true" ] else [])
259
[
260
tag "summary" []
261
[
262
tag "h2"
263
[ class_ "slot-heading" ]
264
[
265
tag "span" [ class_ "slot-number" ] [ txt "%d." (index + 1) ];
266
tag "span"
267
[
268
Dream_html.string_attr "class" "slot-label%s"
269
(if done_ then " slot-label-done" else "");
270
]
271
[ txt "%s" slot.label ];
272
];
273
];
274
tag "div"
275
[ class_ "slot-panel" ]
276
[ tag "div" [ class_ "slot-panel-body" ] body ];
277
]
278
in
279
let slots =
280
[ tag "div" [ class_ "slot-accordions" ] (List.map slot_section vm.slots) ]
281
in
282
let complete_note =
283
if vm.all_recorded then
284
[
285
tag "p"
286
[ class_ "done" ]
287
[ txt "Everything prescribed has been recorded." ];
288
]
289
else []
290
in
291
let finish_section =
292
match record_id with
293
| Some _ -> []
294
| None ->
295
[
296
tag "div"
297
[
298
Dream_html.string_attr "class" "button-group finish-actions%s"
299
(if vm.all_recorded then " finish-actions-complete" else "");
300
]
301
[
302
tag "form"
303
[
304
action Routes.finish_workout;
305
post_form;
306
Dream_html.attr "data-hito-app-form";
307
]
308
[
309
Dream_html.csrf_tag request;
310
void "input" [ type_ "submit"; value "Finish workout" ];
311
];
312
(* Cancel is a native POST form. Without script, it submits
313
immediately. The client only opens the confirm dialog. Its
314
acceptance submits this form as a full document navigation so
315
the cancelled workout cannot leave a stale app shell behind. *)
316
tag "form"
317
[
318
action Routes.cancel_workout;
319
post_form;
320
Dream_html.attr "data-hito-confirm-form";
321
]
322
[
323
Dream_html.csrf_tag request;
324
void "input"
325
[
326
type_ "submit";
327
class_ "secondary";
328
value "Cancel logging";
329
Dream_html.attr "data-hito-confirm-cancel";
330
];
331
];
332
];
333
(* The confirmation modal. Native dialog for built-in focus trapping
334
and Escape-to-close. The client opens it. "Cancel workout" submits
335
the cancel form, "Keep logging" closes it. *)
336
tag "dialog"
337
[
338
class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal";
339
]
340
[
341
tag "h2" [] [ txt "Cancel logging this workout?" ];
342
tag "p" [] [ txt "This action cannot be undone." ];
343
tag "div"
344
[ class_ "button-group" ]
345
[
346
tag "button"
347
[
348
type_ "button"; Dream_html.attr "data-hito-confirm-accept";
349
]
350
[ txt "Cancel logging" ];
351
tag "button"
352
[
353
type_ "button";
354
class_ "secondary";
355
Dream_html.attr "data-hito-confirm-dismiss";
356
]
357
[ txt "Continue logging" ];
358
];
359
];
360
]
361
in
362
html_page ~viewer ~request ~active:"workout" ?workout_started vm.name
363
([
364
tag "h1" [] [ txt "%s" vm.name ];
365
tag "h2" [ class_ "workout-date" ] [ txt "%s" vm.date ];
366
]
367
@ slots @ complete_note @ finish_section
368
@
369
if enhanced then
370
[
371
tag "p"
372
[
373
Dream_html.string_attr "aria-live" "polite";
374
Dream_html.attr "data-hito-workout-status";
375
]
376
[];
377
]
378
else [])
379