[OCaml] High Intensity Training Online
1
open Dream_html
2
3
type page = Dream_html.node
4
5
let tag = Dream_html.std_tag
6
let void = Dream_html.void_tag
7
let class_ = Dream_html.string_attr "class"
8
let name = Dream_html.string_attr "name"
9
let value = Dream_html.string_attr "value"
10
let rel = Dream_html.string_attr "rel"
11
let type_ = Dream_html.string_attr "type"
12
let step = Dream_html.string_attr "step"
13
let id = Dream_html.string_attr "id"
14
let tabindex = Dream_html.string_attr "tabindex"
15
let required = Dream_html.attr "required"
16
let href path = Dream_html.path_attr (Dream_html.uri_attr "href") path
17
let src path = Dream_html.path_attr (Dream_html.uri_attr "src") path
18
let action path = Dream_html.path_attr (Dream_html.uri_attr "action") path
19
let post_form = Dream_html.string_attr "method" "post"
20
let flash_key = "hito.flash"
21
22
(* The shell. [viewer] and [request] are present on authenticated pages, which
23
then show a logout control and the username on the history link. Auth pages
24
omit both. *)
25
let theme_session_key = "hito.theme"
26
27
let html_page ?viewer ?request ?(active = "") ?(logging = false)
28
?(show_nav = false) title content =
29
let spa_client = true in
30
let theme =
31
match request with
32
| Some request -> (
33
match Dream.session_field request theme_session_key with
34
| Some "dark" -> "dark"
35
| _ -> "light")
36
| None -> "light"
37
in
38
let nav_link page path label =
39
let attrs = [ href path ] in
40
let attrs =
41
if spa_client then Dream_html.attr "data-hito-app-link" :: attrs
42
else attrs
43
in
44
let attrs =
45
if String.equal page active then
46
Dream_html.string_attr "aria-current" "page" :: attrs
47
else attrs
48
in
49
tag "a" attrs [ txt "%s" label ]
50
in
51
(* Three always-available actions. The middle slot points to the routine
52
normally and to the workout in progress while logging, so a running
53
workout stays one tap away without adding a fourth destination. The logbook
54
tab always reads "Logbook". The username lives on its own profile control. *)
55
let navigation_links () =
56
[
57
nav_link "home" Routes.home "Home";
58
(if logging then nav_link "workout" Routes.workout "Current workout"
59
else nav_link "routine" Routes.routine "Routine");
60
nav_link "logbook" Routes.logbook "Logbook";
61
]
62
in
63
(* The profile control names the signed-in trainee and opens a drop-down of
64
account actions: Profile, Settings, and Sign out. It sits in the masthead,
65
and on mobile it is the top-bar profile control the bottom nav has no room
66
for. Settings points at the profile page too — account settings live there
67
and there is no separate settings page. A <details> menu needs no client
68
script, so it works before the SPA enhancement loads. The Sign-out form is
69
nested inside and keeps its CSRF token. *)
70
let profile_area =
71
match (viewer, request) with
72
| Some (viewer : View_model.Viewer.t), Some request ->
73
let menu_link page path label =
74
let attrs = [ class_ "profile-menu-item"; href path ] in
75
let attrs = Dream_html.string_attr "role" "menuitem" :: attrs in
76
let attrs =
77
if spa_client then Dream_html.attr "data-hito-app-link" :: attrs
78
else attrs
79
in
80
let attrs =
81
if String.equal page active then
82
Dream_html.string_attr "aria-current" "page" :: attrs
83
else attrs
84
in
85
tag "a" attrs [ txt "%s" label ]
86
in
87
let summary_attrs =
88
if String.equal "profile" active || String.equal "settings" active
89
then
90
[ class_ "profile"; Dream_html.string_attr "aria-current" "page" ]
91
else [ class_ "profile" ]
92
in
93
[
94
tag "details"
95
[ class_ "profile-menu" ]
96
[
97
tag "summary" summary_attrs
98
[ txt "%s" viewer.View_model.Viewer.username ];
99
tag "div"
100
[
101
class_ "profile-menu-drawer";
102
Dream_html.string_attr "role" "menu";
103
Dream_html.string_attr "aria-label" "Profile menu";
104
]
105
[
106
menu_link "profile" Routes.profile "Profile";
107
menu_link "settings" Routes.settings "Settings";
108
menu_link "app-feedback" Routes.app_feedback "Feedback";
109
tag "form"
110
[
111
action Routes.logout;
112
post_form;
113
class_ "logout";
114
Dream_html.attr "data-hito-app-form";
115
]
116
[
117
Dream_html.csrf_tag request;
118
void "input"
119
[
120
type_ "submit";
121
class_ "profile-menu-item";
122
Dream_html.string_attr "role" "menuitem";
123
value "Sign out";
124
];
125
];
126
];
127
];
128
]
129
| _ -> []
130
in
131
let account_area = [] in
132
let primary_nav =
133
match (viewer, request) with
134
| Some (_ : View_model.Viewer.t), Some _ ->
135
[ tag "nav" [ class_ "primary-nav" ] (navigation_links ()) ]
136
| _ when show_nav ->
137
[ tag "nav" [ class_ "primary-nav" ] (navigation_links ()) ]
138
| _ -> []
139
in
140
let bottom_nav =
141
match (viewer, request) with
142
| Some (_ : View_model.Viewer.t), Some _ ->
143
[
144
tag "nav"
145
[
146
class_ "bottom-nav";
147
Dream_html.string_attr "aria-label" "Mobile navigation";
148
]
149
(navigation_links ());
150
]
151
| _ when show_nav ->
152
[
153
tag "nav"
154
[
155
class_ "bottom-nav";
156
Dream_html.string_attr "aria-label" "Mobile navigation";
157
]
158
(navigation_links ());
159
]
160
| _ -> []
161
in
162
let flash_notice =
163
match request with
164
| Some request -> (
165
match Dream.session_field request flash_key with
166
| Some message ->
167
[
168
tag "div"
169
[
170
class_ "toast";
171
Dream_html.attr "data-hito-toast";
172
Dream_html.string_attr "role" "status";
173
Dream_html.string_attr "aria-live" "polite";
174
]
175
[ txt "%s" message ];
176
]
177
| None -> [])
178
| None -> []
179
in
180
tag "html"
181
[
182
Dream_html.string_attr "lang" "en";
183
Dream_html.string_attr "data-theme" "%s" theme;
184
]
185
[
186
tag "head" []
187
[
188
tag "title" [] [ txt "Hito — %s" title ];
189
void "meta" [ Dream_html.string_attr "charset" "utf-8" ];
190
void "meta"
191
[
192
Dream_html.string_attr "name" "viewport";
193
Dream_html.string_attr "content"
194
"width=device-width,initial-scale=1";
195
];
196
void "link" [ rel "stylesheet"; href Routes.stylesheet ];
197
];
198
tag "body"
199
[ Dream_html.string_attr "class" "hito-app" ]
200
(flash_notice
201
@ [
202
tag "a"
203
[
204
class_ "skip-link";
205
Dream_html.string_attr "href" "#main-content";
206
]
207
[ txt "Skip to main content" ];
208
tag "div"
209
([
210
Dream_html.string_attr "class" "app-shell page-%s" active;
211
Dream_html.string_attr "data-hito-page-title" "Hito — %s" title;
212
]
213
@
214
if spa_client then [ Dream_html.attr "data-hito-app-shell" ]
215
else [])
216
([
217
tag "header"
218
[ class_ "masthead" ]
219
([
220
tag "a"
221
([ class_ "brand"; href Routes.home ]
222
@
223
if spa_client then
224
[ Dream_html.attr "data-hito-app-link" ]
225
else [])
226
[ txt "hito" ];
227
]
228
@ primary_nav @ profile_area @ account_area);
229
tag "main"
230
[ id "main-content"; tabindex "-1" ]
231
[
232
tag "div"
233
([ class_ "page-surface" ]
234
@
235
if spa_client then
236
[ Dream_html.attr "data-hito-app-content" ]
237
else [])
238
content;
239
];
240
]
241
@ bottom_nav);
242
]
243
@
244
if spa_client then
245
[
246
tag "script"
247
[ src Routes.workout_client; Dream_html.attr "defer" ]
248
[];
249
]
250
else []);
251
]
252
253
let problem ~title ~detail =
254
html_page title
255
[
256
tag "p" [ class_ "eyebrow" ] [ txt "Attention required" ];
257
tag "h1" [] [ txt "%s" title ];
258
tag "p" [ class_ "warn" ] [ txt "%s" detail ];
259
]
260
261
(* A branded error page for the common HTTP failures. It states only the class
262
of failure, never a server-supplied string, so nothing internal leaks. The
263
navigation stays present — top on desktop, bottom on mobile — so a lost
264
visitor is one tap from a known destination. *)
265
let error_page ?viewer ?request ~status () =
266
let title, detail =
267
match status with
268
| 404 ->
269
("Page not found", "That page does not exist. Use the navigation below.")
270
| 400 -> ("Bad request", "That request could not be understood.")
271
| 403 -> ("Forbidden", "You do not have access to that page.")
272
| s when s >= 500 ->
273
("Something went wrong", "The server hit a problem. Try again shortly.")
274
| _ -> ("Something went wrong", "That request could not be completed.")
275
in
276
html_page ?viewer ?request ~show_nav:true title
277
[
278
tag "p" [ class_ "eyebrow" ] [ txt "%d" status ];
279
tag "h1" [] [ txt "%s" title ];
280
tag "p" [ class_ "warn" ] [ txt "%s" detail ];
281
]
282
283
(* --- authentication --- *)
284
285
let auth_error = function
286
| None -> []
287
| Some message ->
288
[
289
tag "p"
290
[ class_ "warn"; Dream_html.string_attr "role" "alert" ]
291
[ txt "%s" message ];
292
]
293
294
let credentials_form request ~submit ~action_path =
295
tag "form"
296
[ action action_path; post_form; Dream_html.attr "data-hito-app-form" ]
297
[
298
Dream_html.csrf_tag request;
299
tag "div"
300
[ class_ "field" ]
301
[
302
tag "label"
303
[ Dream_html.string_attr "for" "username" ]
304
[ txt "Username" ];
305
void "input"
306
[
307
type_ "text";
308
name "username";
309
Dream_html.string_attr "id" "username";
310
Dream_html.string_attr "autocomplete" "username";
311
required;
312
];
313
];
314
tag "div"
315
[ class_ "field" ]
316
[
317
tag "label"
318
[ Dream_html.string_attr "for" "password" ]
319
[ txt "Password" ];
320
void "input"
321
[
322
type_ "password";
323
name "password";
324
Dream_html.string_attr "id" "password";
325
Dream_html.string_attr "autocomplete" "current-password";
326
required;
327
];
328
];
329
void "input" [ type_ "submit"; value submit ];
330
]
331
332
let login request ?error ?(registration_open = false) () =
333
let register_prompt =
334
if registration_open then
335
[
336
tag "p" []
337
[
338
txt "No account yet? ";
339
tag "a"
340
[ href Routes.register; Dream_html.attr "data-hito-app-link" ]
341
[ txt "Register" ];
342
];
343
]
344
else []
345
in
346
html_page ~active:"auth" "Sign in"
347
([
348
tag "h1" [] [ txt "Sign in" ];
349
credentials_form request ~submit:"Sign in" ~action_path:Routes.login;
350
]
351
@ register_prompt @ auth_error error)
352
353
let register request ?error () =
354
html_page ~active:"auth" "Register"
355
([
356
tag "h1" [] [ txt "Create an account" ];
357
credentials_form request ~submit:"Register" ~action_path:Routes.register;
358
tag "p" []
359
[
360
txt "Already registered? ";
361
tag "a"
362
[ href Routes.login; Dream_html.attr "data-hito-app-link" ]
363
[ txt "Sign in" ];
364
];
365
]
366
@ auth_error error)
367
368
(* --- application pages --- *)
369
370
let error_id input_id = input_id ^ "-error"
371
372
let extension_select ?(selected = "") ?(invalid = false) ~input_id () =
373
let error_attrs =
374
if invalid then
375
[
376
Dream_html.string_attr "aria-invalid" "true";
377
Dream_html.string_attr "aria-describedby" "%s" (error_id input_id);
378
]
379
else []
380
in
381
let option (code : string) label =
382
let attrs = [ Dream_html.string_attr "value" "%s" code ] in
383
let attrs =
384
if String.equal code selected then Dream_html.attr "selected" :: attrs
385
else attrs
386
in
387
tag "option" attrs [ txt "%s" label ]
388
in
389
tag "select"
390
([ name "extension"; Dream_html.string_attr "id" "%s" input_id ]
391
@ error_attrs)
392
[
393
option "" "to positive failure";
394
option "forced" "then forced reps";
395
option "negatives" "then negatives";
396
option "rest-pause" "then rest-pause";
397
option "static" "then a static hold";
398
]
399
400
let choose_routine request ~logging ~viewer
401
~(routines : View_model.Routine_choice.t list) =
402
html_page ~viewer ~request ~active:"home" ~logging "Choose a routine"
403
[
404
tag "h1" [] [ txt "Routines" ];
405
tag "div" []
406
(List.map
407
(fun (choice : View_model.Routine_choice.t) ->
408
tag "form"
409
[
410
action Routes.select_routine choice.id;
411
post_form;
412
Dream_html.attr "data-hito-app-form";
413
]
414
[
415
Dream_html.csrf_tag request;
416
tag "fieldset" []
417
[
418
tag "legend" [] [ txt "%s" choice.name ];
419
tag "p" []
420
[ txt "%d workouts in the cycle." choice.workout_count ];
421
void "input" [ type_ "submit"; value "Use this routine" ];
422
];
423
])
424
routines);
425
]
426
427
let begin_form request ~routine_id ~override label =
428
tag "form"
429
[ action Routes.workout; post_form; Dream_html.attr "data-hito-app-form" ]
430
[
431
Dream_html.csrf_tag request;
432
void "input"
433
[
434
type_ "hidden";
435
name "routine";
436
Dream_html.string_attr "value" "%s" routine_id;
437
];
438
void "input"
439
[
440
type_ "hidden";
441
name "override";
442
Dream_html.string_attr "value" "%s" (string_of_bool override);
443
];
444
void "input" [ type_ "submit"; value label ];
445
]
446
447
(* Beginning under override needs an explicit acknowledgement, so its submit is
448
gated behind a confirmation modal. Without script the form submits at once —
449
the doctrine's deviation stays possible. The modal only makes it deliberate.
450
The client reuses the shared confirm machinery: [data-hito-confirm-cancel]
451
opens the dialog, [data-hito-confirm-form] is the form its accept submits. *)
452
let override_begin request ~routine_id =
453
[
454
tag "form"
455
[
456
action Routes.workout;
457
post_form;
458
Dream_html.attr "data-hito-app-form";
459
Dream_html.attr "data-hito-confirm-form";
460
]
461
[
462
Dream_html.csrf_tag request;
463
void "input"
464
[
465
type_ "hidden";
466
name "routine";
467
Dream_html.string_attr "value" "%s" routine_id;
468
];
469
void "input"
470
[
471
type_ "hidden";
472
name "override";
473
Dream_html.string_attr "value" "true";
474
];
475
void "input"
476
[
477
type_ "submit";
478
value "Begin under override";
479
Dream_html.attr "data-hito-confirm-cancel";
480
];
481
];
482
tag "dialog"
483
[ class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal" ]
484
[
485
tag "h2" [] [ txt "Begin before recovery is complete?" ];
486
tag "p" []
487
[
488
txt
489
"Heavy Duty grows muscle during recovery, not in the gym. \
490
Training early can cut into it. This is recorded as an \
491
override.";
492
];
493
tag "div"
494
[ class_ "button-group" ]
495
[
496
tag "button"
497
[
498
type_ "button";
499
class_ "secondary";
500
Dream_html.attr "data-hito-confirm-dismiss";
501
]
502
[ txt "Wait to recover" ];
503
tag "button"
504
[ type_ "button"; Dream_html.attr "data-hito-confirm-accept" ]
505
[ txt "Begin under override" ];
506
];
507
];
508
]
509
510
let home request ~viewer ~home:(vm : View_model.Home.t) =
511
let status, gate =
512
match vm.gate with
513
| View_model.Home.Ready ->
514
( "Recovery is complete.",
515
[
516
begin_form request ~routine_id:vm.routine_id ~override:false
517
"Begin next workout";
518
] )
519
| View_model.Home.Recovering { status } ->
520
( status,
521
[
522
tag "div"
523
[ class_ "warn" ]
524
([
525
tag "p" []
526
[
527
txt "Heavy Duty requires recovery before the next workout.";
528
];
529
]
530
@ override_begin request ~routine_id:vm.routine_id);
531
] )
532
in
533
html_page ~viewer ~request ~active:"home" "Home"
534
([
535
tag "h1" [] [ txt "%s" vm.routine_name ];
536
tag "p" [ class_ "ledger-meta" ] [ txt "Next: %s" vm.next_workout ];
537
tag "p" [ class_ "ledger-meta" ] [ txt "%s" status ];
538
]
539
@ gate)
540
541
(* Home while a workout is in progress. Rather than the recovery gate or the
542
routine chooser, Home shows a single card: the workout is under way and one
543
tap returns to it. The middle navigation action already reads "Current
544
workout" here, and this card is the matching Home affordance. *)
545
let workout_in_progress request ~viewer ~workout_name =
546
html_page ~viewer ~request ~active:"home" ~logging:true "Home"
547
[
548
tag "a"
549
[
550
class_ "in-progress-card";
551
href Routes.workout;
552
Dream_html.attr "data-hito-app-link";
553
]
554
[
555
tag "p" [ class_ "eyebrow" ] [ txt "Workout in progress" ];
556
tag "h1" [] [ txt "%s" workout_name ];
557
tag "p" [] [ txt "Return to the current workout to keep logging." ];
558
];
559
]
560
561
let routine request ?(logging = false) ~viewer (vm : View_model.Routine.t) =
562
html_page ~viewer ~request ~active:"routine" ~logging "Routine"
563
[
564
tag "h1" [] [ txt "%s" vm.name ];
565
tag "div"
566
[ class_ "routine-accordions" ]
567
(List.map
568
(fun (workout : View_model.Routine.workout) ->
569
tag "details"
570
[ class_ "routine-accordion" ]
571
[
572
tag "summary" [] [ txt "%s" workout.name ];
573
tag "div"
574
[ class_ "routine-description" ]
575
[
576
tag "ul" []
577
(List.map
578
(fun (exercise : View_model.Routine.exercise) ->
579
tag "li" []
580
[
581
tag "span"
582
[ class_ "routine-exercise" ]
583
[ txt "%s" exercise.name ];
584
tag "span"
585
[ class_ "routine-reps" ]
586
[ txt "%s" exercise.reps ];
587
])
588
workout.exercises);
589
];
590
])
591
vm.workouts);
592
]
593
594
let error_for ~input_id (field : string) (errors : (string * string) list) =
595
match List.assoc_opt field errors with
596
| None -> []
597
| Some error ->
598
[
599
tag "p"
600
[
601
Dream_html.string_attr "id" "%s" (error_id input_id);
602
class_ "warn";
603
Dream_html.string_attr "role" "alert";
604
]
605
[ txt "%s" (Decode.errors_to_text [ (field, error) ]) ];
606
]
607
608
let input_row ?value:v ?(invalid = false) ?placeholder ?(hide_label = false)
609
~input_id (field : string) label =
610
let error_attrs =
611
if invalid then
612
[
613
Dream_html.string_attr "aria-invalid" "true";
614
Dream_html.string_attr "aria-describedby" "%s" (error_id input_id);
615
]
616
else []
617
in
618
let placeholder_attr =
619
match placeholder with
620
| None -> []
621
| Some p -> [ Dream_html.string_attr "placeholder" "%s" p ]
622
in
623
tag "div"
624
[ class_ "field" ]
625
[
626
tag "label"
627
[
628
Dream_html.string_attr "for" "%s" input_id;
629
class_ (if hide_label then "sr-only" else "");
630
]
631
[ txt "%s" label ];
632
void "input"
633
([
634
type_ "number";
635
Dream_html.string_attr "name" "%s" field;
636
Dream_html.string_attr "id" "%s" input_id;
637
step "0.5";
638
required;
639
]
640
@ placeholder_attr @ error_attrs
641
@
642
match v with
643
| None -> []
644
| Some v -> [ Dream_html.string_attr "value" "%s" v ]);
645
]
646
647
(* A movement's fields as cells of the fieldset grid: the exercise name, the
648
load input, and the reps input. The cells are direct participants of the one
649
[.logging-grid] the fieldset wraps them in — a [display: contents] wrapper
650
groups a movement without opening a nested grid — so every movement's name,
651
load, and reps columns line up across the whole fieldset. The name reads as a
652
heading, the inputs as labelled fields. *)
653
let movement_row ~(errors : (string * string) list) ~name ~load_id ~load_field
654
~reps_id ~reps_field ?load_value ?reps_value ~reps_label
655
?(hide_labels = false) () =
656
tag "div"
657
[ class_ "movement" ]
658
[
659
tag "p" [ class_ "exercise-name" ] [ txt "%s" name ];
660
input_row ?value:load_value ~placeholder:"kg" ~hide_label:hide_labels
661
~invalid:(List.mem_assoc load_field errors)
662
~input_id:load_id load_field "Load (kg)";
663
input_row ?value:reps_value ~placeholder:"reps" ~hide_label:hide_labels
664
~invalid:(List.mem_assoc reps_field errors)
665
~input_id:reps_id reps_field reps_label;
666
]
667
668
(* A shared heading row for a multi-exercise group: empty name cell, then the
669
Load and Reps column headings. Rendered once as cells of the fieldset grid so
670
the labels are not repeated above every input. *)
671
let exercise_fields_head ~reps_label () =
672
tag "div"
673
[ class_ "movement movement-head" ]
674
[
675
tag "span" [ class_ "exercise-name" ] [ txt "" ];
676
tag "span" [ class_ "col-heading" ] [ txt "Load (kg)" ];
677
tag "span" [ class_ "col-heading" ] [ txt "%s" reps_label ];
678
]
679
680
(* [movements] carries the record/correction field spec the controller built
681
from the slot's prescription: one row for a single exercise, two for a
682
pre-exhaust pair, each already pre-filled when correcting. [submit] names the
683
action. Load and reps of each movement are laid out side by side. *)
684
let form_for_stimulus request ~action_path ~slot
685
~(movements : View_model.Workout.movement list) ~selected_extension
686
?(enhanced = false) ?(submit = "Record") ~(errors : (string * string) list)
687
() =
688
let field_id field = Printf.sprintf "slot-%d-%s" slot field in
689
let selected = selected_extension in
690
(* A single-exercise slot shows one row with a visible "Reps to failure"
691
label. A pre-exhaust pair shows a shared column heading and hides the
692
per-row labels. *)
693
let hide_labels = List.length movements > 1 in
694
let head =
695
match movements with
696
| first :: _ :: _ ->
697
[
698
exercise_fields_head ~reps_label:first.View_model.Workout.reps_label
699
();
700
]
701
| _ -> []
702
in
703
let movement_of (m : View_model.Workout.movement) =
704
movement_row ~errors ~name:m.name ~load_id:(field_id m.load_field)
705
~load_field:m.load_field ~reps_id:(field_id m.reps_field)
706
~reps_field:m.reps_field ?load_value:m.load_value ?reps_value:m.reps_value
707
~reps_label:m.reps_label ~hide_labels ()
708
in
709
let fields = head @ List.map movement_of movements in
710
(* The error rows follow the fields present, so a single-exercise slot reports
711
load/reps and a pre-exhaust slot reports the iso/comp fields. *)
712
let field_names =
713
List.concat_map
714
(fun (m : View_model.Workout.movement) -> [ m.load_field; m.reps_field ])
715
movements
716
@ [ "extension" ]
717
in
718
tag "form"
719
([ action_path; post_form ]
720
@
721
if enhanced then [ Dream_html.attr "data-hito-workout-form" ]
722
else [ Dream_html.attr "data-hito-app-form" ])
723
[
724
Dream_html.csrf_tag request;
725
tag "fieldset"
726
[ class_ "logging-fieldset" ]
727
(tag "div"
728
[ class_ "logging-grid" ]
729
(fields
730
@ [
731
tag "div"
732
[ class_ "field field-ending" ]
733
[
734
tag "label"
735
[
736
Dream_html.string_attr "for" "%s" (field_id "extension");
737
]
738
[ txt "Ending" ];
739
extension_select ~selected
740
~invalid:(List.mem_assoc "extension" errors)
741
~input_id:(field_id "extension") ();
742
];
743
])
744
:: List.concat_map
745
(fun field -> error_for ~input_id:(field_id field) field errors)
746
field_names
747
@ [
748
void "input"
749
[ type_ "submit"; Dream_html.string_attr "value" "%s" submit ];
750
]);
751
]
752
753
let workout request ~viewer ?(errors = []) ?editing ?logging ~record_id
754
~active_slot (vm : View_model.Workout.t) =
755
let enhanced = Option.is_none record_id in
756
(* A live workout view is itself the workout in progress, so the middle nav
757
action reads "Current workout". A saved-record view defers to the handler,
758
which knows whether a separate workout is in progress. *)
759
let logging = Option.value logging ~default:enhanced in
760
let slot_count = List.length vm.slots in
761
let filled_slots =
762
List.length
763
(List.filter
764
(fun (s : View_model.Workout.slot) ->
765
Option.is_some s.recorded_summary)
766
vm.slots)
767
in
768
let record_action slot =
769
match record_id with
770
| None -> action Routes.workout_slot slot
771
| Some record_id -> action Routes.record_slot record_id slot
772
in
773
let edit_action slot =
774
match record_id with
775
| None -> action Routes.workout_slot_edit slot
776
| Some record_id -> action Routes.record_slot_edit record_id slot
777
in
778
(* The early-workout notice. The override was already confirmed in a modal, so
779
on the workout screen this is informational only: a toast rather than a
780
component in the main content. It is a polite live region. The client shows
781
it briefly then dismisses it. Without script it stays visible as a quiet
782
fixed notice, so a no-JS visitor is still told. *)
783
let override_note =
784
if vm.overridden then
785
[
786
tag "div"
787
[
788
class_ "toast";
789
Dream_html.attr "data-hito-toast";
790
Dream_html.string_attr "role" "status";
791
Dream_html.string_attr "aria-live" "polite";
792
]
793
[ txt "Begun before recovery finished." ];
794
]
795
else []
796
in
797
(* The exercise selector: a dropdown naming every prescription slot. Choosing
798
an option opens that exercise below. The selector is a GET form so it works
799
without script — submitting navigates to the slot via a [?slot=] query, the
800
same contract the panel already reads. The client enhances it: it navigates
801
the moment the selection changes, so the submit button is a no-JS fallback.
802
A recorded slot is marked done in its label. *)
803
let select_action_base =
804
match record_id with None -> "/workout" | Some id -> "/logbook/" ^ id
805
in
806
let exercise_group =
807
let option_for (slot : View_model.Workout.slot) =
808
let done_ = Option.is_some slot.recorded_summary in
809
let label =
810
if done_ then Printf.sprintf "%d. %s (done)" (slot.index + 1) slot.label
811
else Printf.sprintf "%d. %s" (slot.index + 1) slot.label
812
in
813
let attrs = [ Dream_html.string_attr "value" "%d" slot.index ] in
814
let attrs =
815
if slot.index = active_slot then Dream_html.attr "selected" :: attrs
816
else attrs
817
in
818
tag "option" attrs [ txt "%s" label ]
819
in
820
let form_attrs =
821
[
822
Dream_html.string_attr "method" "get";
823
Dream_html.string_attr "action" "%s" select_action_base;
824
class_ "exercise-select";
825
]
826
in
827
let form_attrs =
828
if enhanced then Dream_html.attr "data-hito-exercise-form" :: form_attrs
829
else form_attrs
830
in
831
tag "form" form_attrs
832
[
833
tag "label"
834
[ Dream_html.string_attr "for" "exercise-choice"; class_ "sr-only" ]
835
[ txt "Exercise" ];
836
tag "select"
837
[
838
name "slot";
839
id "exercise-choice";
840
Dream_html.attr "data-hito-exercise-select";
841
]
842
(List.map option_for vm.slots);
843
void "input"
844
[
845
type_ "submit"; class_ "secondary exercise-select-go"; value "Open";
846
];
847
]
848
in
849
(* The panel for the active slot. It shows one exercise at a time: a recorded
850
slot renders its read-only summary and a pre-filled correction form (which
851
replaces the slot rather than adding volume). An outstanding slot renders
852
the record form. *)
853
let active_panel =
854
match List.nth_opt vm.slots active_slot with
855
| None ->
856
[
857
tag "p"
858
[ class_ "warn" ]
859
[ txt "That exercise is not part of this workout." ];
860
]
861
| Some (slot : View_model.Workout.slot) ->
862
let errors = if editing = Some active_slot then errors else [] in
863
let heading = tag "h2" [] [ txt "%s" slot.label ] in
864
let body =
865
match slot.recorded_summary with
866
| Some summary ->
867
[
868
tag "p" [ class_ "eyebrow" ] [ txt "Recorded" ];
869
tag "p" [ class_ "done" ] [ txt "%s" summary ];
870
form_for_stimulus request ~action_path:(edit_action active_slot)
871
~slot:active_slot ~movements:slot.movements
872
~selected_extension:slot.selected_extension ~enhanced
873
~submit:"Save correction" ~errors ();
874
]
875
| None ->
876
[
877
form_for_stimulus request
878
~action_path:(record_action active_slot)
879
~slot:active_slot ~movements:slot.movements
880
~selected_extension:slot.selected_extension ~enhanced ~errors
881
();
882
]
883
in
884
[ tag "section" [ class_ "slot-panel" ] (heading :: body) ]
885
in
886
let complete_note =
887
if vm.all_recorded then
888
[
889
tag "p"
890
[ class_ "done" ]
891
[ txt "Everything prescribed has been recorded." ];
892
]
893
else []
894
in
895
(* A sticky timer bar for the workout in progress. It carries the workout's
896
start time as an epoch, and the client ticks the elapsed time from it.
897
Reading the true start on every render means an SPA content swap never
898
resets the count. Only the live workout shows it. *)
899
let timer_bar =
900
if enhanced then
901
let started = vm.started_at_unix in
902
[
903
tag "div"
904
[
905
class_ "workout-timer";
906
Dream_html.attr "data-hito-workout-timer";
907
Dream_html.string_attr "data-started" "%d" started;
908
]
909
[
910
tag "span" [ class_ "workout-timer-label" ] [ txt "Elapsed" ];
911
tag "span"
912
[
913
class_ "workout-timer-value";
914
Dream_html.string_attr "aria-live" "off";
915
Dream_html.attr "data-hito-workout-timer-value";
916
]
917
[ txt "0:00" ];
918
];
919
]
920
else []
921
in
922
let finish_section =
923
match record_id with
924
| Some _ -> []
925
| None ->
926
[
927
tag "div"
928
[ class_ "button-group" ]
929
[
930
tag "form"
931
[
932
action Routes.finish_workout;
933
post_form;
934
Dream_html.attr "data-hito-app-form";
935
]
936
[
937
Dream_html.csrf_tag request;
938
void "input" [ type_ "submit"; value "Finish workout" ];
939
];
940
(* Cancel is a native POST form. Without script, it submits
941
immediately. The client only opens the confirm dialog. Its
942
acceptance submits this form as a full document navigation so
943
the cancelled workout cannot leave a stale app shell behind. *)
944
tag "form"
945
[
946
action Routes.cancel_workout;
947
post_form;
948
Dream_html.attr "data-hito-confirm-form";
949
Dream_html.attr "data-hito-cancel-form";
950
]
951
[
952
Dream_html.csrf_tag request;
953
void "input"
954
[
955
type_ "submit";
956
class_ "secondary";
957
value "Cancel logging";
958
Dream_html.attr "data-hito-confirm-cancel";
959
];
960
];
961
];
962
(* The confirmation modal. Native dialog for built-in focus trapping
963
and Escape-to-close. The client opens it. "Cancel workout" submits
964
the cancel form, "Keep logging" closes it. *)
965
tag "dialog"
966
[
967
class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal";
968
]
969
[
970
tag "h2" [] [ txt "Cancel this workout?" ];
971
tag "p" []
972
[
973
txt
974
"The workout is discarded and leaves no record, then you \
975
return Home.";
976
];
977
tag "div"
978
[ class_ "button-group" ]
979
[
980
tag "button"
981
[
982
type_ "button";
983
class_ "secondary";
984
Dream_html.attr "data-hito-confirm-dismiss";
985
]
986
[ txt "Keep logging" ];
987
tag "button"
988
[
989
type_ "button"; Dream_html.attr "data-hito-confirm-accept";
990
]
991
[ txt "Cancel workout" ];
992
];
993
];
994
]
995
in
996
html_page ~viewer ~request ~active:"workout" ~logging vm.name
997
[
998
tag "div"
999
(if enhanced then
1000
[
1001
Dream_html.attr "data-hito-workout";
1002
Dream_html.attr "data-hito-workout-content";
1003
]
1004
else [])
1005
(timer_bar @ override_note
1006
@ [
1007
tag "h1" [] [ txt "%s" vm.name ];
1008
tag "p"
1009
[ class_ "ledger-meta" ]
1010
[ txt "%d of %d recorded." filled_slots slot_count ];
1011
exercise_group;
1012
]
1013
@ active_panel @ complete_note @ finish_section
1014
@
1015
if enhanced then
1016
[
1017
tag "p"
1018
[
1019
Dream_html.string_attr "aria-live" "polite";
1020
Dream_html.attr "data-hito-workout-status";
1021
]
1022
[];
1023
]
1024
else []);
1025
]
1026
1027
(* The subjective feedback form. Radio groups report sleep, appetite,
1028
readiness, motivation, and difficulty against a personal baseline. Flags
1029
report pain, injury, and insufficient preparation. A field left blank reports
1030
nothing, so the trainee submits only what they mean to. Feedback is
1031
standalone — recorded from the Logbook at any time. *)
1032
type feedback_flow = { step : int; answers : (string * string) list }
1033
1034
let feedback_factors =
1035
[
1036
("sleep", "Sleep");
1037
("appetite", "Appetite");
1038
("readiness", "Readiness");
1039
("motivation", "Motivation");
1040
("difficulty", "Perceived difficulty");
1041
]
1042
1043
(* Each subjective metric uses five unselected radio buttons styled as inline
1044
buttons. The server shows one metric at a time. *)
1045
let feedback_level_buttons field label ~selected =
1046
let choice code text =
1047
tag "label"
1048
[ class_ "feedback-choice"; Dream_html.attr "data-hito-feedback-choice" ]
1049
[
1050
void "input"
1051
([
1052
type_ "radio";
1053
Dream_html.string_attr "name" "choice";
1054
Dream_html.string_attr "value" "%s" code;
1055
Dream_html.string_attr "id" "%s-%s" field code;
1056
]
1057
@
1058
if String.equal code selected then [ Dream_html.attr "checked" ]
1059
else []);
1060
tag "span" [] [ txt "%s" text ];
1061
]
1062
in
1063
tag "div"
1064
[ class_ "feedback-group" ]
1065
[
1066
tag "p" [ class_ "feedback-group-label" ] [ txt "%s" label ];
1067
tag "div"
1068
[
1069
class_ "feedback-buttons";
1070
Dream_html.string_attr "role" "group";
1071
Dream_html.string_attr "aria-label" "%s" label;
1072
]
1073
[
1074
choice "1" "1 — very poor";
1075
choice "2" "2";
1076
choice "3" "3";
1077
choice "4" "4";
1078
choice "5" "5 — very good";
1079
];
1080
]
1081
1082
let feedback_flag field label =
1083
tag "label"
1084
[ class_ "feedback-flag" ]
1085
[
1086
void "input"
1087
[
1088
type_ "checkbox";
1089
Dream_html.string_attr "name" "%s" field;
1090
value "true";
1091
];
1092
txt " %s" label;
1093
]
1094
1095
let feedback_progress step =
1096
let completed = min 5 (max 0 step) in
1097
tag "div"
1098
[ class_ "feedback-progress" ]
1099
[
1100
tag "progress"
1101
[
1102
Dream_html.string_attr "value" "%s" (string_of_int completed);
1103
Dream_html.string_attr "max" "5";
1104
Dream_html.string_attr "aria-label" "Feedback progress";
1105
]
1106
[ txt "%d of 5 factors" completed ];
1107
tag "p" [ class_ "ledger-meta" ] [ txt "%d of 5 factors" completed ];
1108
]
1109
1110
let feedback_action_button ~action ~label ?(secondary = false) () =
1111
tag "button"
1112
([
1113
type_ "submit";
1114
name "action";
1115
value action;
1116
Dream_html.attr "data-hito-feedback-action";
1117
]
1118
@ if secondary then [ class_ "secondary" ] else [])
1119
[ txt "%s" label ]
1120
1121
let feedback_flow_form request flow =
1122
let step = min 5 (max 0 flow.step) in
1123
let fields =
1124
[
1125
void "input"
1126
[
1127
type_ "hidden";
1128
name "step";
1129
Dream_html.string_attr "value" "%s" (string_of_int step);
1130
];
1131
void "input"
1132
[
1133
type_ "hidden";
1134
name "action";
1135
Dream_html.string_attr "value" "";
1136
Dream_html.attr "data-hito-feedback-action-value";
1137
];
1138
Dream_html.csrf_tag request;
1139
]
1140
in
1141
let body =
1142
if step < 5 then
1143
let field, label = List.nth feedback_factors step in
1144
fields
1145
@ [
1146
tag "h3" [] [ txt "%s" label ];
1147
feedback_level_buttons field label
1148
~selected:
1149
(Option.value (List.assoc_opt field flow.answers) ~default:"");
1150
feedback_progress (step + 1);
1151
tag "div"
1152
[ class_ "feedback-actions" ]
1153
((if step > 0 then
1154
let _, previous_label = List.nth feedback_factors (step - 1) in
1155
[
1156
feedback_action_button ~action:"back"
1157
~label:(Printf.sprintf "Back to %s" previous_label)
1158
~secondary:true ();
1159
]
1160
else [])
1161
@ [
1162
feedback_action_button ~action:"skip" ~label:"Skip this factor"
1163
~secondary:true ();
1164
]);
1165
]
1166
else
1167
fields
1168
@ [
1169
tag "h3" [] [ txt "Anything else?" ];
1170
tag "p" []
1171
[ txt "Add an optional note about pain, injury, or preparation." ];
1172
tag "div"
1173
[ class_ "field" ]
1174
[
1175
feedback_flag "pain" "Pain";
1176
feedback_flag "injury" "Injury";
1177
feedback_flag "preparation" "Preparation was insufficient";
1178
];
1179
feedback_progress 5;
1180
tag "div"
1181
[ class_ "feedback-actions" ]
1182
[
1183
feedback_action_button ~action:"back"
1184
~label:"Back to Perceived difficulty" ~secondary:true ();
1185
feedback_action_button ~action:"save" ~label:"Submit feedback" ();
1186
];
1187
]
1188
in
1189
tag "form"
1190
[
1191
action Routes.feedback;
1192
post_form;
1193
class_ "feedback-form";
1194
Dream_html.attr "data-hito-feedback-form";
1195
Dream_html.attr "data-hito-app-form";
1196
]
1197
[ tag "fieldset" [] body ]
1198
1199
let feedback_modal request ~flow ~open_ =
1200
tag "dialog"
1201
([ class_ "feedback-modal"; Dream_html.attr "data-hito-feedback-modal" ]
1202
@ if open_ then [ Dream_html.attr "open" ] else [])
1203
[
1204
tag "div"
1205
[ class_ "feedback-modal-content" ]
1206
[
1207
tag "div"
1208
[ class_ "feedback-modal-heading" ]
1209
[
1210
tag "h2" [] [ txt "How are you feeling?" ];
1211
(* Close cancels the flow: it submits the cancel form, so the
1212
partial progress is discarded rather than kept for later. *)
1213
tag "form"
1214
[
1215
action Routes.cancel_feedback;
1216
post_form;
1217
class_ "feedback-close-form";
1218
Dream_html.attr "data-hito-app-form";
1219
]
1220
[
1221
Dream_html.csrf_tag request;
1222
tag "button"
1223
[
1224
type_ "submit";
1225
class_ "feedback-modal-close";
1226
Dream_html.string_attr "aria-label" "Close feedback";
1227
]
1228
[ txt "×" ];
1229
];
1230
];
1231
feedback_flow_form request flow;
1232
];
1233
]
1234
1235
(* A small inline-SVG line chart of leveled feedback over time, one polyline per
1236
factor. No script and no dependency: the logbook records, and this only draws
1237
what it already holds. Shown once at least two reports carry a leveled
1238
factor, since a single point is not a trend. *)
1239
let feedback_graph (reports : View_model.Feedback.report list) =
1240
let chronological =
1241
List.sort
1242
(fun (a : View_model.Feedback.report) (b : View_model.Feedback.report) ->
1243
Int.compare a.at_unix b.at_unix)
1244
reports
1245
in
1246
let count = List.length chronological in
1247
let score_of field (report : View_model.Feedback.report) =
1248
match List.assoc_opt field report.factor_scores with
1249
| Some s -> s
1250
| None -> None
1251
in
1252
let series =
1253
List.map
1254
(fun (field, label) ->
1255
( label,
1256
List.mapi (fun i report -> (i, score_of field report)) chronological
1257
))
1258
feedback_factors
1259
in
1260
(* A factor charts only if it holds at least two scored points. *)
1261
let plottable (_, points) =
1262
List.length (List.filter (fun (_, s) -> Option.is_some s) points) >= 2
1263
in
1264
let series = List.filter plottable series in
1265
if count < 2 || series = [] then []
1266
else
1267
let width = 480 and height = 180 in
1268
let pad_left = 28 and pad_right = 12 and pad_top = 12 and pad_bottom = 24 in
1269
let plot_w = width - pad_left - pad_right in
1270
let plot_h = height - pad_top - pad_bottom in
1271
let x_of i =
1272
if count = 1 then pad_left + (plot_w / 2)
1273
else pad_left + (i * plot_w / (count - 1))
1274
in
1275
(* Score 1..5 maps low-to-high, so 5 sits at the top. *)
1276
let y_of score = pad_top + ((5 - score) * plot_h / 4) in
1277
let polyline label points =
1278
let coords =
1279
List.filter_map
1280
(fun (i, s) ->
1281
Option.map (fun s -> Printf.sprintf "%d,%d" (x_of i) (y_of s)) s)
1282
points
1283
in
1284
tag "polyline"
1285
[
1286
class_ "feedback-graph-line";
1287
Dream_html.string_attr "points" "%s" (String.concat " " coords);
1288
Dream_html.string_attr "fill" "none";
1289
Dream_html.string_attr "data-factor" "%s" label;
1290
]
1291
[]
1292
in
1293
let axis =
1294
List.map
1295
(fun score ->
1296
let y = y_of score in
1297
tag "text"
1298
[
1299
class_ "feedback-graph-tick";
1300
Dream_html.string_attr "x" "%d" (pad_left - 6);
1301
Dream_html.string_attr "y" "%d" (y + 3);
1302
Dream_html.string_attr "text-anchor" "end";
1303
]
1304
[ txt "%d" score ])
1305
[ 1; 2; 3; 4; 5 ]
1306
in
1307
let legend =
1308
tag "ul"
1309
[ class_ "feedback-graph-legend" ]
1310
(List.map
1311
(fun (label, _) ->
1312
tag "li"
1313
[ Dream_html.string_attr "data-factor" "%s" label ]
1314
[ txt "%s" label ])
1315
series)
1316
in
1317
[
1318
tag "figure"
1319
[ class_ "feedback-graph" ]
1320
[
1321
tag "figcaption" [] [ txt "Feedback over time" ];
1322
tag "svg"
1323
[
1324
class_ "feedback-graph-svg";
1325
Dream_html.string_attr "viewBox" "0 0 %d %d" width height;
1326
Dream_html.string_attr "role" "img";
1327
Dream_html.string_attr "aria-label"
1328
"Subjective feedback scores over time, one line per factor";
1329
]
1330
(axis
1331
@ List.map (fun (label, points) -> polyline label points) series);
1332
legend;
1333
];
1334
]
1335
1336
let feedback_list (reports : View_model.Feedback.report list) =
1337
if reports = [] then []
1338
else
1339
[
1340
tag "ul"
1341
[ class_ "feedback-list" ]
1342
(List.map
1343
(fun (report : View_model.Feedback.report) ->
1344
let signals = String.concat ", " report.signals in
1345
tag "li" []
1346
[ txt "%s" (if signals = "" then "No signals" else signals) ])
1347
reports);
1348
]
1349
1350
let logbook request ?(logging = false) ~viewer ?(feedback = [])
1351
?(suggest_feedback = false) ?feedback_flow ?(feedback_open = false) records
1352
=
1353
let feedback_flow =
1354
Option.value feedback_flow ~default:{ step = 0; answers = [] }
1355
in
1356
html_page ~viewer ~request ~active:"logbook" ~logging "Logbook"
1357
[
1358
tag "h1" [] [ txt "Logbook" ];
1359
(if records = [] then tag "p" [] [ txt "Nothing logged yet." ]
1360
else
1361
tag "ul" []
1362
(List.map
1363
(fun (entry : View_model.Logbook_entry.t) ->
1364
tag "li" []
1365
[
1366
tag "a"
1367
[
1368
href Routes.record entry.id;
1369
Dream_html.attr "data-hito-app-link";
1370
]
1371
[
1372
txt "%s — %d stimuli" entry.workout_name
1373
entry.stimuli_count;
1374
];
1375
tag "p"
1376
[ class_ "done" ]
1377
[
1378
txt "%s"
1379
(if entry.complete then "complete" else "incomplete");
1380
];
1381
])
1382
records));
1383
tag "section"
1384
[ class_ "feedback-section" ]
1385
([
1386
tag "h2" [] [ txt "How are you feeling?" ];
1387
(if suggest_feedback then
1388
tag "p"
1389
[ class_ "eyebrow" ]
1390
[ txt "Workout saved — add feedback while it is fresh." ]
1391
else txt "");
1392
tag "p" []
1393
[
1394
txt
1395
"Report each factor in turn. Skip any factor or the full flow.";
1396
];
1397
tag "a"
1398
[
1399
class_ "feedback-toggle";
1400
Dream_html.string_attr "href" "/logbook?feedback=start";
1401
Dream_html.attr "data-hito-feedback-open";
1402
]
1403
[ txt "Record feedback" ];
1404
feedback_modal request ~flow:feedback_flow ~open_:feedback_open;
1405
]
1406
@ feedback_graph feedback @ feedback_list feedback);
1407
]
1408
1409
(* The authenticated application feedback page. It keeps writing and reviewing
1410
feedback in separate tabs, while both tabs remain plain links for no-script
1411
use and progressive enhancement. *)
1412
let app_feedback_tab ~selected ~tab ~label =
1413
let path =
1414
match tab with
1415
| `Write -> "/app-feedback?tab=write"
1416
| `Submitted -> "/app-feedback?tab=submitted"
1417
in
1418
let attrs =
1419
[
1420
class_ "feedback-tab";
1421
Dream_html.string_attr "href" "%s" path;
1422
Dream_html.attr "data-hito-app-link";
1423
]
1424
in
1425
let attrs =
1426
if selected = tab then Dream_html.string_attr "aria-current" "page" :: attrs
1427
else attrs
1428
in
1429
tag "a" attrs [ txt "%s" label ]
1430
1431
let app_feedback_form request ?error () =
1432
let error_block =
1433
match error with
1434
| None -> []
1435
| Some message ->
1436
[
1437
tag "p"
1438
[ class_ "warn"; Dream_html.string_attr "role" "alert" ]
1439
[ txt "%s" message ];
1440
]
1441
in
1442
tag "form"
1443
[
1444
action Routes.submit_app_feedback;
1445
post_form;
1446
class_ "app-feedback-form";
1447
Dream_html.attr "data-hito-app-form";
1448
]
1449
(Dream_html.csrf_tag request
1450
:: tag "label"
1451
[ Dream_html.string_attr "for" "app-feedback-message" ]
1452
[ txt "Your feedback" ]
1453
:: tag "textarea"
1454
[
1455
name "message";
1456
id "app-feedback-message";
1457
Dream_html.string_attr "rows" "8";
1458
required;
1459
Dream_html.string_attr "placeholder"
1460
"Tell us what works well or what needs attention.";
1461
]
1462
[]
1463
:: error_block
1464
@ [ void "input" [ type_ "submit"; value "Submit feedback" ] ])
1465
1466
let app_feedback_vote_form request (report : View_model.App_feedback.t) =
1467
if report.viewer_owns then
1468
tag "p" [ class_ "app-feedback-own" ] [ txt "Your feedback" ]
1469
else
1470
let label =
1471
if report.viewer_upvoted then "Upvoted"
1472
else Printf.sprintf "Upvote (%d)" report.upvotes
1473
in
1474
let attrs =
1475
[
1476
action Routes.upvote_app_feedback report.id;
1477
post_form;
1478
class_ "app-feedback-vote";
1479
Dream_html.attr "data-hito-app-form";
1480
]
1481
in
1482
let attrs =
1483
if report.viewer_upvoted then Dream_html.attr "disabled" :: attrs
1484
else attrs
1485
in
1486
tag "form" attrs
1487
[
1488
Dream_html.csrf_tag request;
1489
void "input"
1490
[ type_ "submit"; Dream_html.string_attr "value" "%s" label ];
1491
]
1492
1493
let app_feedback_edit_form request (report : View_model.App_feedback.t) =
1494
tag "form"
1495
[
1496
action Routes.edit_app_feedback report.id;
1497
post_form;
1498
class_ "app-feedback-edit-form";
1499
Dream_html.attr "data-hito-app-form";
1500
]
1501
[
1502
Dream_html.csrf_tag request;
1503
tag "label"
1504
[ Dream_html.string_attr "for" "app-feedback-edit-%s" report.id ]
1505
[ txt "Edit feedback" ];
1506
tag "textarea"
1507
[
1508
name "message";
1509
Dream_html.string_attr "id" "app-feedback-edit-%s" report.id;
1510
Dream_html.string_attr "rows" "4";
1511
required;
1512
]
1513
[ txt "%s" report.message ];
1514
void "input" [ type_ "submit"; value "Save edit" ];
1515
]
1516
1517
let app_feedback_remove_form request (report : View_model.App_feedback.t) =
1518
tag "form"
1519
[
1520
action Routes.remove_app_feedback report.id;
1521
post_form;
1522
class_ "app-feedback-remove-form";
1523
Dream_html.attr "data-hito-app-form";
1524
]
1525
[
1526
Dream_html.csrf_tag request;
1527
void "input" [ type_ "submit"; value "Remove feedback" ];
1528
]
1529
1530
let app_feedback_actions request (report : View_model.App_feedback.t) =
1531
if report.viewer_owns then
1532
tag "div"
1533
[ class_ "app-feedback-actions" ]
1534
[
1535
app_feedback_edit_form request report;
1536
app_feedback_remove_form request report;
1537
]
1538
else
1539
tag "div"
1540
[ class_ "app-feedback-actions" ]
1541
[ app_feedback_vote_form request report ]
1542
1543
let app_feedback request ?(logging = false) ~viewer ?(tab = `Write) ?error
1544
reports =
1545
let panel =
1546
match tab with
1547
| `Write ->
1548
[
1549
tag "h2" [] [ txt "Write feedback" ];
1550
tag "p" []
1551
[ txt "Tell us about your experience using the application." ];
1552
app_feedback_form request ?error ();
1553
]
1554
| `Submitted ->
1555
[
1556
tag "h2" [] [ txt "Submitted feedback" ];
1557
(if reports = [] then
1558
tag "p" []
1559
[ txt "You have not submitted any application feedback." ]
1560
else
1561
tag "ul"
1562
[ class_ "app-feedback-list" ]
1563
(List.map
1564
(fun (report : View_model.App_feedback.t) ->
1565
tag "li" []
1566
[
1567
tag "p"
1568
[ class_ "app-feedback-author" ]
1569
[
1570
txt "%s — %d contributions" report.author
1571
report.contributions;
1572
];
1573
tag "time"
1574
[
1575
class_ "app-feedback-time";
1576
Dream_html.string_attr "datetime" "%s"
1577
report.submitted_at;
1578
]
1579
[ txt "%s" report.submitted_at ];
1580
tag "p"
1581
[ class_ "app-feedback-message" ]
1582
[ txt "%s" report.message ];
1583
tag "p"
1584
[ class_ "app-feedback-upvotes" ]
1585
[ txt "Upvotes: %d" report.upvotes ];
1586
app_feedback_actions request report;
1587
])
1588
reports));
1589
]
1590
in
1591
html_page ~viewer ~request ~active:"app-feedback" ~logging "App feedback"
1592
[
1593
tag "div" []
1594
([
1595
tag "h1" [] [ txt "App feedback" ];
1596
tag "p" [] [ txt "Help us improve hito by sharing your experience." ];
1597
tag "nav"
1598
[
1599
class_ "feedback-tabs";
1600
Dream_html.string_attr "aria-label" "App feedback tabs";
1601
]
1602
[
1603
app_feedback_tab ~selected:tab ~tab:`Write
1604
~label:"Write feedback";
1605
app_feedback_tab ~selected:tab ~tab:`Submitted
1606
~label:"Submitted feedback";
1607
];
1608
]
1609
@ panel);
1610
]
1611
1612
(* The account profile page. It names the signed-in trainee and offers two
1613
independent forms: rename the account, and change the password. Each form
1614
reports its own error inline. [notice] confirms a successful change. Both are
1615
plain authenticated app-forms, so they work with or without script. *)
1616
let profile request ?(logging = false) ~viewer ?username_error ?password_error
1617
?notice () =
1618
let notice_block =
1619
match notice with
1620
| Some message -> [ tag "p" [ class_ "notice" ] [ txt "%s" message ] ]
1621
| None -> []
1622
in
1623
let error_block = function
1624
| Some message ->
1625
[
1626
tag "p"
1627
[ class_ "warn"; Dream_html.string_attr "role" "alert" ]
1628
[ txt "%s" message ];
1629
]
1630
| None -> []
1631
in
1632
html_page ~viewer ~request ~active:"profile" ~logging "Profile"
1633
([ tag "h1" [] [ txt "Profile" ] ]
1634
@ notice_block
1635
@ [
1636
(* Username: show the current value and a button that opens the edit
1637
modal. The modal holds the rename form. A no-script client, or an
1638
error re-render, opens the dialog through the [open] attribute so the
1639
form stays reachable and its inline error shows. *)
1640
tag "section"
1641
[ class_ "profile-section" ]
1642
[
1643
tag "h2" [] [ txt "Username" ];
1644
tag "div"
1645
[ class_ "profile-value" ]
1646
[
1647
tag "span" [ class_ "profile-value-label" ] [ txt "Username" ];
1648
tag "span"
1649
[ class_ "profile-value-text" ]
1650
[ txt "%s" viewer.View_model.Viewer.username ];
1651
tag "button"
1652
[
1653
type_ "button";
1654
class_ "profile-edit";
1655
Dream_html.string_attr "data-hito-dialog-open"
1656
"username-dialog";
1657
]
1658
[ txt "Change username" ];
1659
];
1660
tag "dialog"
1661
([
1662
Dream_html.string_attr "id" "username-dialog";
1663
class_ "profile-dialog";
1664
]
1665
@
1666
if Option.is_some username_error then [ Dream_html.attr "open" ]
1667
else [])
1668
[
1669
tag "div"
1670
[ class_ "profile-dialog-content" ]
1671
([
1672
tag "h3" [] [ txt "Change username" ];
1673
tag "form"
1674
[
1675
action Routes.profile_username;
1676
post_form;
1677
class_ "profile-form";
1678
Dream_html.attr "data-hito-app-form";
1679
]
1680
[
1681
Dream_html.csrf_tag request;
1682
tag "div"
1683
[ class_ "field" ]
1684
[
1685
tag "label"
1686
[ Dream_html.string_attr "for" "username" ]
1687
[ txt "Username" ];
1688
void "input"
1689
[
1690
type_ "text";
1691
name "username";
1692
Dream_html.string_attr "id" "username";
1693
Dream_html.string_attr "value" "%s"
1694
viewer.View_model.Viewer.username;
1695
required;
1696
];
1697
];
1698
tag "div"
1699
[ class_ "profile-dialog-actions" ]
1700
[
1701
tag "button"
1702
[
1703
type_ "button";
1704
class_ "secondary";
1705
Dream_html.attr "data-hito-dialog-close";
1706
]
1707
[ txt "Cancel" ];
1708
void "input"
1709
[ type_ "submit"; value "Change username" ];
1710
];
1711
];
1712
]
1713
@ error_block username_error);
1714
];
1715
];
1716
(* Password: a button opens the change-password modal. *)
1717
tag "section"
1718
[ class_ "profile-section" ]
1719
[
1720
tag "h2" [] [ txt "Password" ];
1721
tag "div"
1722
[ class_ "profile-value" ]
1723
[
1724
tag "span" [ class_ "profile-value-label" ] [ txt "Password" ];
1725
tag "span" [ class_ "profile-value-text" ] [ txt "••••••••" ];
1726
tag "button"
1727
[
1728
type_ "button";
1729
class_ "profile-edit";
1730
Dream_html.string_attr "data-hito-dialog-open"
1731
"password-dialog";
1732
]
1733
[ txt "Change password" ];
1734
];
1735
tag "dialog"
1736
([
1737
Dream_html.string_attr "id" "password-dialog";
1738
class_ "profile-dialog";
1739
]
1740
@
1741
if Option.is_some password_error then [ Dream_html.attr "open" ]
1742
else [])
1743
[
1744
tag "div"
1745
[ class_ "profile-dialog-content" ]
1746
([
1747
tag "h3" [] [ txt "Change password" ];
1748
tag "form"
1749
[
1750
action Routes.profile_password;
1751
post_form;
1752
class_ "profile-form";
1753
Dream_html.attr "data-hito-app-form";
1754
]
1755
[
1756
Dream_html.csrf_tag request;
1757
tag "div"
1758
[ class_ "field" ]
1759
[
1760
tag "label"
1761
[ Dream_html.string_attr "for" "current" ]
1762
[ txt "Current password" ];
1763
void "input"
1764
[
1765
type_ "password";
1766
name "current";
1767
Dream_html.string_attr "id" "current";
1768
required;
1769
];
1770
];
1771
tag "div"
1772
[ class_ "field" ]
1773
[
1774
tag "label"
1775
[ Dream_html.string_attr "for" "next" ]
1776
[ txt "New password" ];
1777
void "input"
1778
[
1779
type_ "password";
1780
name "next";
1781
Dream_html.string_attr "id" "next";
1782
required;
1783
];
1784
];
1785
tag "div"
1786
[ class_ "profile-dialog-actions" ]
1787
[
1788
tag "button"
1789
[
1790
type_ "button";
1791
class_ "secondary";
1792
Dream_html.attr "data-hito-dialog-close";
1793
]
1794
[ txt "Cancel" ];
1795
void "input"
1796
[ type_ "submit"; value "Change password" ];
1797
];
1798
];
1799
]
1800
@ error_block password_error);
1801
];
1802
];
1803
(* Import: a multipart form uploads a native Hevy CSV export. The file
1804
field is [csv], the wall-clock offset is [utc_offset_seconds], and an
1805
optional checkbox overrides duplicate-source rejection. The form is a
1806
plain multipart POST with its CSRF token, so it needs no script. *)
1807
tag "section"
1808
[ class_ "profile-section" ]
1809
[
1810
tag "h2" [] [ txt "Import" ];
1811
tag "form"
1812
[
1813
action Routes.import_upload;
1814
post_form;
1815
Dream_html.string_attr "enctype" "multipart/form-data";
1816
class_ "profile-form";
1817
]
1818
[
1819
Dream_html.csrf_tag request;
1820
tag "div"
1821
[ class_ "field" ]
1822
[
1823
tag "label"
1824
[ Dream_html.string_attr "for" "csv" ]
1825
[ txt "Hevy CSV export" ];
1826
void "input"
1827
[
1828
type_ "file";
1829
name "csv";
1830
Dream_html.string_attr "id" "csv";
1831
Dream_html.string_attr "accept" ".csv,text/csv";
1832
required;
1833
];
1834
];
1835
tag "div"
1836
[ class_ "field" ]
1837
[
1838
tag "label"
1839
[ Dream_html.string_attr "for" "utc_offset_seconds" ]
1840
[ txt "UTC offset in seconds" ];
1841
void "input"
1842
[
1843
type_ "number";
1844
name "utc_offset_seconds";
1845
Dream_html.string_attr "id" "utc_offset_seconds";
1846
Dream_html.string_attr "value" "0";
1847
required;
1848
];
1849
];
1850
tag "div"
1851
[ class_ "field" ]
1852
[
1853
void "input"
1854
[
1855
type_ "checkbox";
1856
name "allow_duplicate";
1857
Dream_html.string_attr "id" "allow_duplicate";
1858
value "true";
1859
];
1860
tag "label"
1861
[ Dream_html.string_attr "for" "allow_duplicate" ]
1862
[ txt "Import even if this export was imported before" ];
1863
];
1864
void "input" [ type_ "submit"; value "Upload import" ];
1865
];
1866
];
1867
])
1868
1869
(* The settings page. It currently stores the user's light or dark theme choice
1870
in the session. *)
1871
let settings request ?(logging = false) ~viewer ~theme () =
1872
let next_theme, label =
1873
match theme with
1874
| "dark" -> ("light", "Use light theme")
1875
| _ -> ("dark", "Use dark theme")
1876
in
1877
html_page ~viewer ~request ~active:"settings" ~logging "Settings"
1878
[
1879
tag "h1" [] [ txt "Settings" ];
1880
tag "section"
1881
[ class_ "settings-section" ]
1882
[
1883
tag "h2" [] [ txt "Theme" ];
1884
tag "p" [] [ txt "Choose a light or dark theme for this session." ];
1885
tag "form"
1886
[ action Routes.settings_theme; post_form; class_ "settings-form" ]
1887
[
1888
Dream_html.csrf_tag request;
1889
void "input"
1890
[
1891
type_ "hidden";
1892
name "theme";
1893
Dream_html.string_attr "value" "%s" next_theme;
1894
];
1895
void "input"
1896
[ type_ "submit"; Dream_html.string_attr "value" "%s" label ];
1897
];
1898
];
1899
]
1900
1901
(* The review of a provisional import batch. It renders the parse warnings, then
1902
each workout with its sets, blockers, and forms. Every form is a plain POST
1903
carrying its CSRF token, so cleaning and promotion need no script. The page
1904
names only the view model: the controller has already phrased set types,
1905
blockers, and warnings, so no core entity crosses the boundary here. *)
1906
let import_review request ~viewer (vm : View_model.Import_review.t) =
1907
let warnings_block =
1908
match vm.View_model.Import_review.warnings with
1909
| [] -> []
1910
| warnings ->
1911
[
1912
tag "section"
1913
[ class_ "import-warnings" ]
1914
[
1915
tag "h2" [] [ txt "Dropped rows" ];
1916
tag "ul" []
1917
(List.map
1918
(fun (w : View_model.Import_warning.t) ->
1919
tag "li" []
1920
[
1921
txt "Row %d: %s" w.View_model.Import_warning.row
1922
w.View_model.Import_warning.detail;
1923
])
1924
warnings);
1925
];
1926
]
1927
in
1928
(* One row per set. A mapped set shows the catalog name it resolves to. *)
1929
let set_row (s : View_model.Import_set.t) =
1930
let mapping =
1931
match s.View_model.Import_set.mapping with
1932
| Some name -> Printf.sprintf " → %s" name
1933
| None -> " → unmapped"
1934
in
1935
tag "li" []
1936
[
1937
txt "Row %d: %s (%s) %s kg × %s reps%s" s.View_model.Import_set.row
1938
s.View_model.Import_set.exercise_name s.View_model.Import_set.set_type
1939
s.View_model.Import_set.weight s.View_model.Import_set.reps mapping;
1940
]
1941
in
1942
(* One mapping form per distinct source name in the workout. The source name
1943
is fixed by a hidden field; the trainee supplies the catalog exercise id. *)
1944
let mapping_form batch_id workout_id source_name =
1945
tag "form"
1946
[
1947
action Routes.import_map batch_id workout_id;
1948
post_form;
1949
class_ "import-map-form";
1950
]
1951
[
1952
Dream_html.csrf_tag request;
1953
void "input"
1954
[
1955
type_ "hidden";
1956
name "source_name";
1957
Dream_html.string_attr "value" "%s" source_name;
1958
];
1959
tag "div"
1960
[ class_ "field" ]
1961
[
1962
tag "label" [] [ txt "Map %s to exercise id" source_name ];
1963
void "input" [ type_ "text"; name "exercise_id"; required ];
1964
];
1965
void "input" [ type_ "submit"; value "Map exercise" ];
1966
]
1967
in
1968
let confirm_form batch_id workout_id acknowledgement =
1969
tag "form"
1970
[
1971
action Routes.import_confirm batch_id workout_id;
1972
post_form;
1973
class_ "import-confirm-form";
1974
]
1975
[
1976
Dream_html.csrf_tag request;
1977
tag "div"
1978
[ class_ "field" ]
1979
[
1980
tag "label" [] [ txt "Confirm normal sets" ];
1981
void "input"
1982
([ type_ "text"; name "acknowledgement"; required ]
1983
@
1984
match acknowledgement with
1985
| Some ack -> [ Dream_html.string_attr "value" "%s" ack ]
1986
| None -> []);
1987
];
1988
void "input" [ type_ "submit"; value "Confirm normal sets" ];
1989
]
1990
in
1991
let promote_workout_form batch_id workout_id =
1992
tag "form"
1993
[
1994
action Routes.import_promote_workout batch_id workout_id;
1995
post_form;
1996
class_ "import-promote-workout-form";
1997
]
1998
[
1999
Dream_html.csrf_tag request;
2000
void "input" [ type_ "submit"; value "Promote workout" ];
2001
]
2002
in
2003
let blockers_block (w : View_model.Import_workout.t) =
2004
match w.View_model.Import_workout.blockers with
2005
| [] -> [ tag "p" [ class_ "import-ready" ] [ txt "Ready to promote." ] ]
2006
| blockers ->
2007
[
2008
tag "p" [] [ txt "Blocked:" ];
2009
tag "ul"
2010
[ class_ "import-blockers" ]
2011
(List.map (fun b -> tag "li" [] [ txt "%s" b ]) blockers);
2012
]
2013
in
2014
let workout_section (w : View_model.Import_workout.t) =
2015
let batch_id = vm.View_model.Import_review.batch_id in
2016
let workout_id = w.View_model.Import_workout.id in
2017
(* Every distinct source name, in first-appearance order. *)
2018
let source_names =
2019
List.fold_left
2020
(fun acc (s : View_model.Import_set.t) ->
2021
let n = s.View_model.Import_set.exercise_name in
2022
if List.mem n acc then acc else acc @ [ n ])
2023
[] w.View_model.Import_workout.sets
2024
in
2025
tag "section"
2026
[ class_ "import-workout" ]
2027
([
2028
tag "h2" [] [ txt "%s" w.View_model.Import_workout.title ];
2029
tag "ul"
2030
[ class_ "import-sets" ]
2031
(List.map set_row w.View_model.Import_workout.sets);
2032
]
2033
@ blockers_block w
2034
@ List.map (mapping_form batch_id workout_id) source_names
2035
@ [
2036
confirm_form batch_id workout_id
2037
w.View_model.Import_workout.acknowledgement;
2038
promote_workout_form batch_id workout_id;
2039
])
2040
in
2041
let batch_promote_form =
2042
tag "form"
2043
[
2044
action Routes.import_promote_batch vm.View_model.Import_review.batch_id;
2045
post_form;
2046
class_ "import-promote-batch-form";
2047
]
2048
[
2049
Dream_html.csrf_tag request;
2050
void "input" [ type_ "submit"; value "Promote whole batch" ];
2051
]
2052
in
2053
html_page ~viewer ~request ~active:"profile" "Import review"
2054
([ tag "h1" [] [ txt "Import review" ] ]
2055
@ warnings_block
2056
@ List.map workout_section vm.View_model.Import_review.workouts
2057
@ [ batch_promote_form ])
2058