[OCaml] High Intensity Training Online
feat enhance app shell navigation
Keep the server responsible for every workflow and validation outcome while\nletting the client replace rendered shells in place. This preserves the\nCSRF-protected no-JavaScript path and prevents late responses from replacing\nnewer UI state.
Changed files
ARCHITECTURE.md
@@ -71,15 +71,15 @@
71
71
`Trainee.id`. There is no server-wide state: a trainee's active routine and
72
72
workout in progress are stored, not held in a slot.
73
73
- **`hito.web`** — `Routes`, `Decode`, `Pages`, `Handlers`, and a
74
Removed:
workout-only `js_of_ocaml` client. Dream and dream-html live only here.
74
Added:
authenticated-app `js_of_ocaml` client. Dream and dream-html live only here.
75
75
Wall-clock time enters here, never in the core.
76
76
`Handlers` is a functor over `Repository.S`, so production runs on
77
Removed:
`Sqlite_repo` while tests run on `Memory_repo`. The current workout page ships
78
Removed:
a small `js_of_ocaml` enhancement client: it replaces only marked exercise
79
Removed:
panels and record/correction responses in place. Its server-rendered links
80
Removed:
and CSRF-protected forms remain the fallback, so Home, Routine, Logbook,
81
Removed:
saved history, finish/cancel, and every JavaScript-disabled workflow keep
82
Removed:
ordinary browser navigation. Authentication uses one
77
Added:
`Sqlite_repo` while tests run on `Memory_repo`. Authenticated pages ship a
78
Added:
small `js_of_ocaml` enhancement client: it replaces marked app content for
79
Added:
navigation, routine selection, workout start/finish/cancel, saved-record
80
Added:
edits, current workout logging, sign-in, registration, and sign-out. Every
81
Added:
JavaScript-disabled workflow retains ordinary browser navigation and
82
Added:
CSRF-protected server forms. Authentication uses one
83
83
`authenticated` combinator: it resolves the signed-in trainee and passes it
84
84
to a continuation, so a route with any number of path captures shares one
85
85
gate. Every domain and service error becomes a user-facing sentence in one
lib/web/pages.ml
@@ -20,9 +20,14 @@
20
20
(* The shell. [trainee] and [request] are present on authenticated pages, which
21
21
then show a logout control and the username on the history link. Auth pages
22
22
omit both. *)
23
Removed:
let html_page ?trainee ?request ?(active = "") ?(workout_client = false) title
24
Removed:
content =
25
Removed:
let nav_link path label = tag "a" [ href path ] [ txt "%s" label ] in
23
Added:
let html_page ?trainee ?request ?(active = "") title content =
24
Added:
let spa_client = true in
25
Added:
let nav_link path label =
26
Added:
tag "a"
27
Added:
([ href path ]
28
Added:
@ if spa_client then [ Dream_html.attr "data-hito-app-link" ] else [])
29
Added:
[ txt "%s" label ]
30
Added:
in
26
31
let navigation_links username =
27
32
[
28
33
nav_link Routes.home "Overview";
@@ -39,8 +44,13 @@
39
44
[ class_ "account" ]
40
45
[
41
46
tag "form"
42
Removed:
[ action Routes.logout; post_form; class_ "logout" ]
43
47
[
48
Added:
action Routes.logout;
49
Added:
post_form;
50
Added:
class_ "logout";
51
Added:
Dream_html.attr "data-hito-app-form";
52
Added:
]
53
Added:
[
44
54
Dream_html.csrf_tag request;
45
55
void "input" [ type_ "submit"; value "Sign out" ];
46
56
];
@@ -86,8 +96,13 @@
86
96
(navigation_links (Trainee.username_to_string trainee.username)
87
97
@ [
88
98
tag "form"
89
Removed:
[ action Routes.logout; post_form; class_ "logout" ]
90
99
[
100
Added:
action Routes.logout;
101
Added:
post_form;
102
Added:
class_ "logout";
103
Added:
Dream_html.attr "data-hito-app-form";
104
Added:
]
105
Added:
[
91
106
Dream_html.csrf_tag request;
92
107
void "input" [ type_ "submit"; value "Sign out" ];
93
108
];
@@ -127,31 +142,50 @@
127
142
[ Dream_html.string_attr "class" "hito-app page-%s" active ]
128
143
([
129
144
tag "div"
130
Removed:
[ class_ "app-shell" ]
131
Removed:
[
132
Removed:
tag "header"
133
Removed:
[ class_ "masthead" ]
134
Removed:
([
135
Removed:
tag "a"
136
Removed:
[ class_ "brand"; href Routes.home ]
137
Removed:
[
138
Removed:
tag "span" [ class_ "brand-mark" ] [ txt "HD" ];
139
Removed:
tag "span"
140
Removed:
[ class_ "brand-copy" ]
141
Removed:
[
142
Removed:
tag "strong" [] [ txt "hito" ];
143
Removed:
tag "small" []
144
Removed:
[ txt "High Intensity Tracker Online" ];
145
Removed:
];
146
Removed:
];
147
Removed:
]
148
Removed:
@ primary_nav @ account_area @ menu);
149
Removed:
tag "main" [] [ tag "div" [ class_ "page-surface" ] content ];
150
Removed:
];
145
Added:
([
146
Added:
Dream_html.string_attr "class" "app-shell page-%s" active;
147
Added:
Dream_html.string_attr "data-hito-page-title" "%s — hito" title;
148
Added:
]
149
Added:
@
150
Added:
if spa_client then [ Dream_html.attr "data-hito-app-shell" ]
151
Added:
else [])
152
Added:
([
153
Added:
tag "header"
154
Added:
[ class_ "masthead" ]
155
Added:
([
156
Added:
tag "a"
157
Added:
([ class_ "brand"; href Routes.home ]
158
Added:
@
159
Added:
if spa_client then
160
Added:
[ Dream_html.attr "data-hito-app-link" ]
161
Added:
else [])
162
Added:
[
163
Added:
tag "span" [ class_ "brand-mark" ] [ txt "HD" ];
164
Added:
tag "span"
165
Added:
[ class_ "brand-copy" ]
166
Added:
[
167
Added:
tag "strong" [] [ txt "hito" ];
168
Added:
tag "small" []
169
Added:
[ txt "High Intensity Tracker Online" ];
170
Added:
];
171
Added:
];
172
Added:
]
173
Added:
@ primary_nav @ account_area @ menu);
174
Added:
tag "main" []
175
Added:
[
176
Added:
tag "div"
177
Added:
([ class_ "page-surface" ]
178
Added:
@
179
Added:
if spa_client then
180
Added:
[ Dream_html.attr "data-hito-app-content" ]
181
Added:
else [])
182
Added:
content;
183
Added:
];
184
Added:
]
185
Added:
@ bottom_nav);
151
186
]
152
Removed:
@ bottom_nav
153
187
@
154
Removed:
if workout_client then
188
Added:
if spa_client then
155
189
[
156
190
tag "script"
157
191
[ src Routes.workout_client; Dream_html.attr "defer" ]
@@ -176,7 +210,7 @@
176
210
177
211
let credentials_form request ~submit ~action_path =
178
212
tag "form"
179
Removed:
[ action action_path; post_form ]
213
Added:
[ action action_path; post_form; Dream_html.attr "data-hito-app-form" ]
180
214
[
181
215
Dream_html.csrf_tag request;
182
216
tag "div"
@@ -219,7 +253,9 @@
219
253
tag "p" []
220
254
[
221
255
txt "No account yet? ";
222
Removed:
tag "a" [ href Routes.register ] [ txt "Register" ];
256
Added:
tag "a"
257
Added:
[ href Routes.register; Dream_html.attr "data-hito-app-link" ]
258
Added:
[ txt "Register" ];
223
259
];
224
260
]
225
261
else []
@@ -239,7 +275,9 @@
239
275
tag "p" []
240
276
[
241
277
txt "Already registered? ";
242
Removed:
tag "a" [ href Routes.login ] [ txt "Sign in" ];
278
Added:
tag "a"
279
Added:
[ href Routes.login; Dream_html.attr "data-hito-app-link" ]
280
Added:
[ txt "Sign in" ];
243
281
];
244
282
]
245
283
@ auth_error error)
@@ -288,7 +326,9 @@
288
326
(fun (routine_id, routine) ->
289
327
tag "form"
290
328
[
291
Removed:
action Routes.select_routine (id_string routine_id); post_form;
329
Added:
action Routes.select_routine (id_string routine_id);
330
Added:
post_form;
331
Added:
Dream_html.attr "data-hito-app-form";
292
332
]
293
333
[
294
334
Dream_html.csrf_tag request;
@@ -309,7 +349,7 @@
309
349
310
350
let begin_form request ~routine ~override label =
311
351
tag "form"
312
Removed:
[ action Routes.workout; post_form ]
352
Added:
[ action Routes.workout; post_form; Dream_html.attr "data-hito-app-form" ]
313
353
[
314
354
Dream_html.csrf_tag request;
315
355
void "input"
@@ -505,7 +545,9 @@
505
545
in
506
546
tag "form"
507
547
([ action_path; post_form ]
508
Removed:
@ if enhanced then [ Dream_html.attr "data-hito-workout-form" ] else [])
548
Added:
@
549
Added:
if enhanced then [ Dream_html.attr "data-hito-workout-form" ]
550
Added:
else [ Dream_html.attr "data-hito-app-form" ])
509
551
[
510
552
Dream_html.csrf_tag request;
511
553
tag "fieldset" []
@@ -736,14 +778,22 @@
736
778
[ class_ "button-group" ]
737
779
[
738
780
tag "form"
739
Removed:
[ action Routes.finish_workout; post_form ]
740
781
[
782
Added:
action Routes.finish_workout;
783
Added:
post_form;
784
Added:
Dream_html.attr "data-hito-app-form";
785
Added:
]
786
Added:
[
741
787
Dream_html.csrf_tag request;
742
788
void "input" [ type_ "submit"; value "Finish workout" ];
743
789
];
744
790
tag "form"
745
Removed:
[ action Routes.cancel_workout; post_form ]
746
791
[
792
Added:
action Routes.cancel_workout;
793
Added:
post_form;
794
Added:
Dream_html.attr "data-hito-app-form";
795
Added:
]
796
Added:
[
747
797
Dream_html.csrf_tag request;
748
798
void "input"
749
799
[
@@ -753,7 +803,7 @@
753
803
];
754
804
]
755
805
in
756
Removed:
html_page ~trainee ~request ~active:"workout" ~workout_client:enhanced
806
Added:
html_page ~trainee ~request ~active:"workout"
757
807
(Prescription.Workout.name prescription)
758
808
[
759
809
tag "div"
@@ -815,7 +865,10 @@
815
865
tag "li" []
816
866
[
817
867
tag "a"
818
Removed:
[ href Routes.record (record.Repository.id :> string) ]
868
Added:
[
869
Added:
href Routes.record (record.Repository.id :> string);
870
Added:
Dream_html.attr "data-hito-app-link";
871
Added:
]
819
872
[
820
873
txt "%s — %d stimuli"
821
874
(Prescription.Workout.name
lib/web/pages.mli
@@ -36,11 +36,12 @@
36
36
Evidence.Workout.t ->
37
37
page
38
38
(** Renders one prescribed slot at a time under a vertical group of named
39
Removed:
exercise buttons. Current workouts carry progressive-enhancement markers for
40
Removed:
the js_of_ocaml client, while saved records always use ordinary links and
41
Removed:
forms. [active_slot] selects the visible button; a handler clamps a
42
Removed:
requested slot and falls back to {!default_slot}. [record_id] is [None] for
43
Removed:
the workout in progress and [Some id] for a saved history record. *)
39
Added:
exercise buttons. Authenticated pages carry generic progressive-enhancement
40
Added:
markers; current workouts additionally mark slot links and forms, while
41
Added:
saved-record edits use the generic authenticated form path. [active_slot]
42
Added:
button; a handler clamps a requested slot and falls back to {!default_slot}.
43
Added:
[record_id] is [None] for the workout in progress and [Some id] for a saved
44
Added:
history record. *)
44
45
45
46
val default_slot : Evidence.Workout.t -> int
46
47
(** The slot a workout view opens on: the first slot still awaiting a record, or
lib/web/workout_client.ml
@@ -1,9 +1,8 @@
1
1
open Js_of_ocaml
2
2
open Lwt.Infix
3
3
4
Removed:
let selector = "[data-hito-workout-content]"
5
Removed:
let enhanced_link = "a[data-hito-workout-link]"
6
Removed:
let enhanced_form = "form[data-hito-workout-form]"
4
Added:
let workout_selector = "[data-hito-workout-content]"
5
Added:
let app_selector = "[data-hito-app-shell]"
7
6
let document = Dom_html.document
8
7
let request_number = ref 0
9
8
@@ -17,50 +16,68 @@
17
16
| None -> None
18
17
| Some node -> Js.Opt.to_option (Dom_html.CoerceTo.element node)
19
18
19
Added:
let set_busy busy =
20
Added:
match query_one document app_selector with
21
Added:
| Some shell ->
22
Added:
shell##setAttribute (Js.string "aria-busy")
23
Added:
(Js.string (if busy then "true" else "false"))
24
Added:
| None -> ()
25
Added:
26
Added:
let fallback url = Dom_html.window##.location##assign (Js.string url)
27
Added:
20
28
let announce message =
21
29
match query_one document "[data-hito-workout-status]" with
22
30
| Some status -> status##.textContent := Js.some (Js.string message)
23
31
| None -> ()
24
32
25
Removed:
let fallback url = Dom_html.window##.location##assign (Js.string url)
26
Removed:
27
Removed:
let replace_from_html html =
33
Added:
let replace selector html =
28
34
let next = Dom_html.createDiv document in
29
35
next##.innerHTML := Js.string html;
30
36
match (query_one document selector, query_one next selector) with
31
37
| Some current, Some replacement ->
32
38
let parent = Js.Opt.get current##.parentNode (fun () -> assert false) in
33
39
Dom.replaceChild parent replacement current;
34
Removed:
(match query_one document "[data-hito-workout-content] h1" with
40
Added:
(match
41
Added:
Js.Opt.to_option
42
Added:
(replacement##getAttribute (Js.string "data-hito-page-title"))
43
Added:
with
44
Added:
| Some title -> document##.title := title
45
Added:
| None -> ());
46
Added:
(match query_one document (selector ^ " h1, " ^ selector ^ " h2") with
35
47
| Some heading ->
36
48
heading##setAttribute (Js.string "tabindex") (Js.string "-1");
37
49
heading##focus
38
50
| None -> ());
39
Removed:
announce "Workout updated";
51
Added:
announce "Page updated";
40
52
true
41
53
| _ -> false
42
54
43
Removed:
let get ?(push = false) url =
55
Added:
let replace_app = replace app_selector
56
Added:
let replace_workout = replace workout_selector
57
Added:
58
Added:
let push_url url =
59
Added:
Dom_html.window##.history##pushState
60
Added:
Js.null (Js.string "")
61
Added:
(Js.some (Js.string url))
62
Added:
63
Added:
let get ?(push = false) ?(workout = false) url =
44
64
incr request_number;
45
65
let request = !request_number in
46
Removed:
announce "Loading workout";
47
Removed:
Js_of_ocaml_lwt.XmlHttpRequest.perform_raw_url ~with_credentials:true
48
Removed:
~headers:[ ("X-Hito-Workout", "1") ]
49
Removed:
url
66
Added:
set_busy true;
67
Added:
announce "Loading";
68
Added:
Js_of_ocaml_lwt.XmlHttpRequest.perform_raw_url ~with_credentials:true url
50
69
>>= fun response ->
70
Added:
let replace = if workout then replace_workout else replace_app in
51
71
if request <> !request_number then Lwt.return_unit
52
Removed:
else if
53
Removed:
response.code >= 200 && response.code < 300
54
Removed:
&& replace_from_html response.content
55
Removed:
then (
56
Removed:
if push then
57
Removed:
Dom_html.window##.history##pushState
58
Removed:
Js.null (Js.string "")
59
Removed:
(Js.some (Js.string url));
60
Removed:
Lwt.return_unit)
61
72
else (
62
Removed:
fallback url;
63
Removed:
Lwt.return_unit)
73
Added:
set_busy false;
74
Added:
if response.code >= 200 && response.code < 300 && replace response.content
75
Added:
then (
76
Added:
if push then push_url url;
77
Added:
Lwt.return_unit)
78
Added:
else (
79
Added:
fallback url;
80
Added:
Lwt.return_unit))
64
81
65
82
let modified event =
66
83
Js.to_bool event##.metaKey
@@ -68,18 +85,28 @@
68
85
|| Js.to_bool event##.shiftKey
69
86
|| Js.to_bool event##.altKey
70
87
88
Added:
let href link =
89
Added:
Js.Opt.to_option (link##getAttribute (Js.string "href"))
90
Added:
|> Option.map Js.to_string
91
Added:
71
92
let link_click event =
72
93
if event##.button <> 0 || modified event then Js._true
73
94
else
74
Removed:
match closest enhanced_link (Dom_html.eventTarget event) with
95
Added:
match
96
Added:
closest "a[data-hito-app-link], a[data-hito-workout-link]"
97
Added:
(Dom_html.eventTarget event)
98
Added:
with
75
99
| None -> Js._true
76
100
| Some link -> (
77
Removed:
match Js.Opt.to_option (link##getAttribute (Js.string "href")) with
101
Added:
match href link with
78
102
| None -> Js._true
79
Removed:
| Some href ->
80
Removed:
let url = Js.to_string href in
103
Added:
| Some url ->
81
104
Dom.preventDefault event;
82
Removed:
Lwt.async (fun () -> get ~push:true url);
105
Added:
let workout =
106
Added:
Js.to_bool
107
Added:
(link##hasAttribute (Js.string "data-hito-workout-link"))
108
Added:
in
109
Added:
Lwt.async (fun () -> get ~push:true ~workout url);
83
110
Js._false)
84
111
85
112
let slot_from_action action =
@@ -88,7 +115,10 @@
88
115
| [] -> ""
89
116
90
117
let form_submit event =
91
Removed:
match closest enhanced_form (Dom_html.eventTarget event) with
118
Added:
match
119
Added:
closest "form[data-hito-app-form], form[data-hito-workout-form]"
120
Added:
(Dom_html.eventTarget event)
121
Added:
with
92
122
| None -> Js._true
93
123
| Some element -> (
94
124
match Js.Opt.to_option (Dom_html.CoerceTo.form element) with
@@ -100,9 +130,13 @@
100
130
Js.Opt.to_option (form##getAttribute (Js.string "action"))
101
131
with
102
132
| Some action -> Js.to_string action
103
Removed:
| None -> "/workout"
133
Added:
| None -> "/"
104
134
in
105
Removed:
let slot = slot_from_action action in
135
Added:
incr request_number;
136
Added:
let request = !request_number in
137
Added:
let workout =
138
Added:
Js.to_bool (form##hasAttribute (Js.string "data-hito-workout-form"))
139
Added:
in
106
140
let submit =
107
141
match
108
142
Js.Opt.to_option
@@ -113,21 +147,33 @@
113
147
in
114
148
Dom.preventDefault event;
115
149
Option.iter (fun input -> input##.disabled := Js._true) submit;
116
Removed:
announce "Saving workout";
150
Added:
set_busy true;
151
Added:
announce "Saving";
117
152
Lwt.async (fun () ->
118
153
Js_of_ocaml_lwt.XmlHttpRequest.perform_raw_url
119
154
~with_credentials:true ~override_method:`POST
120
155
~contents:(`Form_contents (Form.post_form_contents form))
121
156
action
122
157
>>= fun response ->
123
Removed:
Option.iter (fun input -> input##.disabled := Js._false) submit;
124
Removed:
if response.code = 400 && replace_from_html response.content then
125
Removed:
Lwt.return_unit
126
Removed:
else if response.code >= 200 && response.code < 300 then
127
Removed:
get ("/workout?slot=" ^ slot)
158
Added:
if request <> !request_number then Lwt.return_unit
128
159
else (
129
Removed:
fallback action;
130
Removed:
Lwt.return_unit));
160
Added:
set_busy false;
161
Added:
Option.iter (fun input -> input##.disabled := Js._false) submit;
162
Added:
let replace =
163
Added:
if workout then replace_workout else replace_app
164
Added:
in
165
Added:
if response.code = 400 && replace response.content then
166
Added:
Lwt.return_unit
167
Added:
else if workout && response.code >= 200 && response.code < 300
168
Added:
then
169
Added:
get ~workout:true ("/workout?slot=" ^ slot_from_action action)
170
Added:
else if response.code < 500 && replace response.content then (
171
Added:
if response.code >= 200 && response.code < 300 then
172
Added:
push_url response.url;
173
Added:
Lwt.return_unit)
174
Added:
else (
175
Added:
fallback action;
176
Added:
Lwt.return_unit)));
131
177
Js._false)
132
178
133
179
let popstate _ =
test/test_web.ml
@@ -143,7 +143,12 @@
143
143
(contains ~substring:"dream.csrf" page);
144
144
Alcotest.(check bool)
145
145
"has a password field" true
146
Removed:
(contains ~substring:"type=\"password\"" page) );
146
Added:
(contains ~substring:"type=\"password\"" page);
147
Added:
Alcotest.(check bool)
148
Added:
"loads the app client and marks the sign-in form" true
149
Added:
(contains ~substring:"data-hito-app-shell" page
150
Added:
&& contains ~substring:"data-hito-app-form" page
151
Added:
&& contains ~substring:"/assets/workout-client.js" page) );
147
152
( "registration signs the trainee in and reaches the overview",
148
153
`Quick,
149
154
fun () ->
@@ -158,8 +163,8 @@
158
163
(contains ~substring:"Routines" (body overview));
159
164
Alcotest.(check bool)
160
165
"shows the username in history navigation" true
161
Removed:
(contains ~substring:"href=\"/history\">lifter</a>"
162
Removed:
(body overview));
166
Added:
(contains ~substring:"href=\"/history\"" (body overview)
167
Added:
&& contains ~substring:">lifter</a>" (body overview));
163
168
Alcotest.(check bool)
164
169
"omits the header username" false
165
170
(contains ~substring:"account-username" (body overview));
@@ -331,6 +336,15 @@
331
336
let c = client () in
332
337
let _ = sign_in_new c in
333
338
let select_page = body (get c "/") in
339
Added:
Alcotest.(check bool)
340
Added:
"enhances authenticated overview navigation" true
341
Added:
(contains ~substring:"data-hito-app-link" select_page
342
Added:
&& contains ~substring:"data-hito-app-content" select_page
343
Added:
&& contains ~substring:"data-hito-page-title" select_page
344
Added:
&& contains ~substring:"/assets/workout-client.js" select_page);
345
Added:
Alcotest.(check bool)
346
Added:
"marks routine selection for in-place submission" true
347
Added:
(contains ~substring:"data-hito-app-form" select_page);
334
348
let token = Option.get (csrf_token select_page) in
335
349
let selection =
336
350
post c "/routines/ideal/select" [ ("dream.csrf", token) ]
@@ -534,8 +548,15 @@
534
548
"does not mark saved history as replaceable workout content" false
535
549
(contains ~substring:"data-hito-workout-content" record_page);
536
550
Alcotest.(check bool)
537
Removed:
"does not load the workout client for saved history" false
551
Added:
"marks saved history as generic app content" true
552
Added:
(contains ~substring:"data-hito-app-content" record_page);
553
Added:
Alcotest.(check bool)
554
Added:
"loads the app client for saved history navigation" true
538
555
(contains ~substring:"/assets/workout-client.js" record_page);
556
Added:
Alcotest.(check bool)
557
Added:
"enhances saved-slot corrections through the generic form path"
558
Added:
true
559
Added:
(contains ~substring:"data-hito-app-form" record_page);
539
560
Alcotest.(check bool)
540
561
"offers a saved-slot correction form" true
541
562
(contains ~substring:"/history/w1/slots/1/edit" record_page);