[OCaml] High Intensity Training Online
1
open Js_of_ocaml
2
open Lwt.Infix
3
4
let workout_selector = "[data-hito-workout-content]"
5
let app_selector = "[data-hito-app-shell]"
6
let document = Dom_html.document
7
let request_number = ref 0
8
9
let query_one root selector =
10
match Js.Opt.to_option (root##querySelector (Js.string selector)) with
11
| None -> None
12
| Some node -> Js.Opt.to_option (Dom_html.CoerceTo.element node)
13
14
let closest selector element =
15
match Js.Opt.to_option (element##closest (Js.string selector)) with
16
| None -> None
17
| Some node -> Js.Opt.to_option (Dom_html.CoerceTo.element node)
18
19
let set_busy busy =
20
match query_one document app_selector with
21
| Some shell ->
22
shell##setAttribute (Js.string "aria-busy")
23
(Js.string (if busy then "true" else "false"))
24
| None -> ()
25
26
let fallback url = Dom_html.window##.location##assign (Js.string url)
27
28
let announce message =
29
match query_one document "[data-hito-workout-status]" with
30
| Some status -> status##.textContent := Js.some (Js.string message)
31
| None -> ()
32
33
(* The early-workout toast. The server renders it visible so a no-JS visitor
34
still reads it. With script it is a transient notice: mark it shown, then
35
remove it after three seconds (WCAG 2.2.1 gives users time to read a brief
36
status). Idempotent: a toast already dismissed carries [data-hito-toast-done]
37
and is left alone, so a content swap never re-arms the same notice. *)
38
let dismiss_toast () =
39
match query_one document "[data-hito-toast]" with
40
| None -> ()
41
| Some toast -> (
42
match
43
Js.Opt.to_option
44
(toast##getAttribute (Js.string "data-hito-toast-done"))
45
with
46
| Some _ -> ()
47
| None ->
48
toast##setAttribute (Js.string "data-hito-toast-done") (Js.string "");
49
ignore
50
(Dom_html.window##setTimeout
51
(Js.wrap_callback (fun () ->
52
match Js.Opt.to_option toast##.parentNode with
53
| Some parent -> Dom.removeChild parent toast
54
| None -> ()))
55
(Js.number_of_float 3000.)))
56
57
(* The workout timer. The sticky bar carries the workout's start time as an
58
epoch in [data-started]. The elapsed time is computed from it, so a content
59
swap that re-renders the bar never resets the count. A single interval runs
60
at a time: [start_timer] clears the previous one before it reads the bar the
61
latest render produced. When no bar is present, the timer stops. *)
62
let timer_interval = ref None
63
64
let format_elapsed seconds =
65
let seconds = if seconds < 0 then 0 else seconds in
66
let minutes = seconds / 60 in
67
let secs = seconds mod 60 in
68
Printf.sprintf "%d:%02d" minutes secs
69
70
let tick () =
71
match query_one document "[data-hito-workout-timer]" with
72
| None -> ()
73
| Some bar -> (
74
match Js.Opt.to_option (bar##getAttribute (Js.string "data-started")) with
75
| None -> ()
76
| Some started -> (
77
(* Epoch seconds exceed js_of_ocaml's 31-bit [int] range, so the
78
start and the current time are kept as floats. Only the small
79
elapsed difference is narrowed to an [int]. Doing the subtraction
80
in [int] would overflow and yield a wrong count. *)
81
let started =
82
try float_of_string (String.trim (Js.to_string started))
83
with _ -> 0.
84
in
85
let now = Js.to_float (new%js Js.date_now)##getTime /. 1000. in
86
let elapsed = int_of_float (now -. started) in
87
match query_one document "[data-hito-workout-timer-value]" with
88
| None -> ()
89
| Some value ->
90
value##.textContent :=
91
Js.some (Js.string (format_elapsed elapsed))))
92
93
let start_timer () =
94
(match !timer_interval with
95
| Some id ->
96
Dom_html.window##clearInterval id;
97
timer_interval := None
98
| None -> ());
99
match query_one document "[data-hito-workout-timer]" with
100
| None -> ()
101
| Some _ ->
102
tick ();
103
let id =
104
Dom_html.window##setInterval
105
(Js.wrap_callback (fun () -> tick ()))
106
(Js.number_of_float 1000.)
107
in
108
timer_interval := Some id
109
110
let feedback_modal () = query_one document "[data-hito-feedback-modal]"
111
112
let open_feedback_modal () =
113
match feedback_modal () with
114
| None -> ()
115
| Some modal ->
116
modal##removeAttribute (Js.string "open");
117
ignore (Js.Unsafe.meth_call modal "showModal" [||])
118
119
let close_feedback_modal () =
120
match feedback_modal () with
121
| None -> ()
122
| Some modal -> ignore (Js.Unsafe.meth_call modal "close" [||])
123
124
(* A generic modal keyed by id: [data-hito-dialog-open="id"] opens the dialog
125
with that id, and [data-hito-dialog-close] closes its nearest dialog. Used by
126
the profile page so username and password edits open in a modal, while a
127
no-script client still shows the same server-rendered form. *)
128
let open_dialog id =
129
match query_one document (Printf.sprintf "dialog#%s" id) with
130
| None -> ()
131
| Some modal ->
132
modal##removeAttribute (Js.string "open");
133
ignore (Js.Unsafe.meth_call modal "showModal" [||])
134
135
let dialog_click event =
136
let target = Dom_html.eventTarget event in
137
match closest "[data-hito-dialog-open]" target with
138
| Some trigger -> (
139
match
140
Js.Opt.to_option
141
(trigger##getAttribute (Js.string "data-hito-dialog-open"))
142
with
143
| None -> Js._true
144
| Some id ->
145
Dom.preventDefault event;
146
open_dialog (Js.to_string id);
147
Js._false)
148
| None -> (
149
match closest "[data-hito-dialog-close]" target with
150
| None -> Js._true
151
| Some close -> (
152
match closest "dialog" close with
153
| None -> Js._true
154
| Some modal ->
155
Dom.preventDefault event;
156
ignore (Js.Unsafe.meth_call modal "close" [||]);
157
Js._false))
158
159
let open_marked_feedback_modal () =
160
match feedback_modal () with
161
| Some modal when Js.to_bool (modal##hasAttribute (Js.string "open")) ->
162
open_feedback_modal ()
163
| _ -> ()
164
165
let replace selector html =
166
let next = Dom_html.createDiv document in
167
next##.innerHTML := Js.string html;
168
match (query_one document selector, query_one next selector) with
169
| Some current, Some replacement ->
170
let parent = Js.Opt.get current##.parentNode (fun () -> assert false) in
171
Dom.replaceChild parent replacement current;
172
(match
173
Js.Opt.to_option
174
(replacement##getAttribute (Js.string "data-hito-page-title"))
175
with
176
| Some title -> document##.title := title
177
| None -> ());
178
(match query_one document (selector ^ " h1, " ^ selector ^ " h2") with
179
| Some heading ->
180
heading##setAttribute (Js.string "tabindex") (Js.string "-1");
181
heading##focus
182
| None -> ());
183
(* No "Page updated" announcement: moving focus to the swapped-in heading
184
already tells assistive tech the content changed, so a status message
185
would be redundant noise. Transient "Loading"/"Saving" still clear when
186
the swap lands, because the fresh status node starts empty. *)
187
start_timer ();
188
dismiss_toast ();
189
open_marked_feedback_modal ();
190
true
191
| _ -> false
192
193
let replace_app = replace app_selector
194
let replace_workout = replace workout_selector
195
196
let push_url url =
197
Dom_html.window##.history##pushState
198
Js.null (Js.string "")
199
(Js.some (Js.string url))
200
201
let get ?(push = false) ?(workout = false) url =
202
incr request_number;
203
let request = !request_number in
204
set_busy true;
205
announce "Loading";
206
Js_of_ocaml_lwt.XmlHttpRequest.perform_raw_url ~with_credentials:true url
207
>>= fun response ->
208
let replace = if workout then replace_workout else replace_app in
209
if request <> !request_number then Lwt.return_unit
210
else (
211
set_busy false;
212
if response.code >= 200 && response.code < 300 && replace response.content
213
then (
214
if push then push_url url;
215
Lwt.return_unit)
216
else (
217
fallback url;
218
Lwt.return_unit))
219
220
let modified event =
221
Js.to_bool event##.metaKey
222
|| Js.to_bool event##.ctrlKey
223
|| Js.to_bool event##.shiftKey
224
|| Js.to_bool event##.altKey
225
226
let href link =
227
Js.Opt.to_option (link##getAttribute (Js.string "href"))
228
|> Option.map Js.to_string
229
230
let link_click event =
231
if event##.button <> 0 || modified event then Js._true
232
else
233
match
234
closest "a[data-hito-app-link], a[data-hito-workout-link]"
235
(Dom_html.eventTarget event)
236
with
237
| None -> Js._true
238
| Some link -> (
239
match href link with
240
| None -> Js._true
241
| Some url ->
242
Dom.preventDefault event;
243
let workout =
244
Js.to_bool
245
(link##hasAttribute (Js.string "data-hito-workout-link"))
246
in
247
Lwt.async (fun () -> get ~push:true ~workout url);
248
Js._false)
249
250
let slot_from_action action =
251
match List.rev (String.split_on_char '/' action) with
252
| "edit" :: slot :: _ | slot :: _ -> slot
253
| [] -> ""
254
255
let form_submit event =
256
match
257
closest "form[data-hito-app-form], form[data-hito-workout-form]"
258
(Dom_html.eventTarget event)
259
with
260
| None -> Js._true
261
| Some element -> (
262
match Js.Opt.to_option (Dom_html.CoerceTo.form element) with
263
| None -> Js._true
264
| Some form when not (Js.to_bool form##checkValidity) -> Js._true
265
| Some form ->
266
let action =
267
match
268
Js.Opt.to_option (form##getAttribute (Js.string "action"))
269
with
270
| Some action -> Js.to_string action
271
| None -> "/"
272
in
273
incr request_number;
274
let request = !request_number in
275
let workout =
276
Js.to_bool (form##hasAttribute (Js.string "data-hito-workout-form"))
277
in
278
let feedback =
279
Js.to_bool
280
(form##hasAttribute (Js.string "data-hito-feedback-form"))
281
in
282
let submit =
283
match
284
Js.Opt.to_option
285
(form##querySelector (Js.string "input[type=submit]"))
286
with
287
| None -> None
288
| Some input -> Js.Opt.to_option (Dom_html.CoerceTo.input input)
289
in
290
Dom.preventDefault event;
291
Option.iter (fun input -> input##.disabled := Js._true) submit;
292
set_busy true;
293
announce "Saving";
294
(* Dream.form accepts URL-encoded forms. FormData produces a
295
multipart request, which requires Dream.multipart instead. Keep
296
this shared client path URL-encoded because all app forms use
297
ordinary fields, including textareas. *)
298
let contents =
299
Form.get_form_contents form
300
|> List.map (fun (name, value) -> (name, `String (Js.string value)))
301
in
302
Lwt.async (fun () ->
303
Js_of_ocaml_lwt.XmlHttpRequest.perform_raw_url
304
~with_credentials:true ~override_method:`POST
305
~contents:(`POST_form contents) action
306
>>= fun response ->
307
if request <> !request_number then Lwt.return_unit
308
else (
309
set_busy false;
310
Option.iter (fun input -> input##.disabled := Js._false) submit;
311
let replace =
312
if workout then replace_workout else replace_app
313
in
314
if response.code = 400 && replace response.content then
315
Lwt.return_unit
316
else if workout && response.code >= 200 && response.code < 300
317
then
318
get ~workout:true ("/workout?slot=" ^ slot_from_action action)
319
else if response.code < 500 && replace response.content then (
320
if response.code >= 200 && response.code < 300 then
321
push_url response.url;
322
Lwt.return_unit)
323
else (
324
fallback (if feedback then "/logbook" else action);
325
Lwt.return_unit)));
326
Js._false)
327
328
let popstate _ =
329
Lwt.async (fun () -> get (Js.to_string Dom_html.window##.location##.href));
330
Js._true
331
332
(* The cancel confirmation modal. The cancel form submits directly without
333
script. With script, its submit is intercepted only to open a native dialog.
334
"Cancel workout" submits the native form as a full document POST. "Keep
335
logging" and Escape close the dialog. The dialog and the pending form are
336
found fresh on each click, so a content swap never leaves a stale
337
reference. *)
338
let confirm_modal () = query_one document "[data-hito-confirm-modal]"
339
340
let close_modal () =
341
match confirm_modal () with
342
| None -> ()
343
| Some modal -> ignore (Js.Unsafe.meth_call modal "close" [||])
344
345
let open_modal () =
346
match confirm_modal () with
347
| None -> ()
348
| Some modal -> ignore (Js.Unsafe.meth_call modal "showModal" [||])
349
350
let pending_confirm_form () =
351
match query_one document "[data-hito-confirm-form]" with
352
| None -> None
353
| Some element -> Js.Opt.to_option (Dom_html.CoerceTo.form element)
354
355
let confirm_click event =
356
let target = Dom_html.eventTarget event in
357
match closest "[data-hito-confirm-cancel]" target with
358
| Some _ ->
359
(* Opening the modal replaces immediate cancellation. *)
360
Dom.preventDefault event;
361
open_modal ();
362
Js._false
363
| None -> (
364
match closest "[data-hito-confirm-dismiss]" target with
365
| Some _ ->
366
Dom.preventDefault event;
367
close_modal ();
368
Js._false
369
| None -> (
370
match closest "[data-hito-confirm-accept]" target with
371
| Some _ -> (
372
Dom.preventDefault event;
373
close_modal ();
374
match pending_confirm_form () with
375
| Some form ->
376
ignore (Js.Unsafe.meth_call form "requestSubmit" [||]);
377
Js._false
378
| None -> Js._false)
379
| None -> Js._true))
380
381
(* The feedback flow uses the same native dialog behavior as workout
382
cancellation. The trigger remains a normal link, so no-script clients open
383
the server-rendered modal page instead. *)
384
let feedback_click event =
385
let target = Dom_html.eventTarget event in
386
match closest "[data-hito-feedback-open]" target with
387
| Some _ ->
388
Dom.preventDefault event;
389
open_feedback_modal ();
390
Js._false
391
| None -> (
392
match closest "[data-hito-feedback-dismiss]" target with
393
| Some _ ->
394
Dom.preventDefault event;
395
close_feedback_modal ();
396
Js._false
397
| None -> (
398
match closest "[data-hito-feedback-action]" target with
399
| None -> Js._true
400
| Some action -> (
401
match closest "form" action with
402
| None -> Js._true
403
| Some form -> (
404
match
405
Js.Opt.to_option
406
(form##getAttribute
407
(Js.string "data-hito-feedback-pending"))
408
with
409
| Some _ ->
410
Dom.preventDefault event;
411
Js._false
412
| None -> (
413
match
414
Js.Opt.to_option
415
(action##getAttribute (Js.string "value"))
416
with
417
| None -> Js._true
418
| Some value -> (
419
match
420
query_one form "[data-hito-feedback-action-value]"
421
with
422
| None -> Js._true
423
| Some hidden ->
424
form##setAttribute
425
(Js.string "data-hito-feedback-pending")
426
(Js.string "");
427
hidden##setAttribute (Js.string "value") value;
428
Js._true))))))
429
430
(* Selecting a score advances to the next factor without a separate Next
431
action. Handle click rather than change so selecting the current score again
432
follows the same path. Defer the submit by one event-loop tick so the browser
433
applies the radio value before form data is collected. *)
434
let feedback_choice event =
435
match closest "[data-hito-feedback-choice]" (Dom_html.eventTarget event) with
436
| None -> Js._true
437
| Some choice -> (
438
match closest "form" choice with
439
| None -> Js._true
440
| Some form -> (
441
match
442
Js.Opt.to_option
443
(form##getAttribute (Js.string "data-hito-feedback-pending"))
444
with
445
| Some _ -> Js._false
446
| None -> (
447
match query_one form "[data-hito-feedback-action-value]" with
448
| None -> Js._true
449
| Some hidden ->
450
form##setAttribute
451
(Js.string "data-hito-feedback-pending")
452
(Js.string "");
453
ignore
454
(Dom_html.window##setTimeout
455
(Js.wrap_callback (fun () ->
456
hidden##setAttribute (Js.string "value")
457
(Js.string "next");
458
ignore
459
(Js.Unsafe.meth_call form "requestSubmit" [||])))
460
(Js.number_of_float 0.));
461
Js._true)))
462
463
(* The exercise dropdown navigates the moment its selection changes. It lives in
464
a GET form ([data-hito-exercise-form]) that submits to the workout path. The
465
client turns a change into a workout-scoped content swap to [?slot=N], so no
466
submit click is needed. Without script the form's submit button still works. *)
467
let exercise_change event =
468
match closest "[data-hito-exercise-form]" (Dom_html.eventTarget event) with
469
| None -> Js._true
470
| Some form -> (
471
let action =
472
match Js.Opt.to_option (form##getAttribute (Js.string "action")) with
473
| Some action -> Js.to_string action
474
| None -> "/workout"
475
in
476
match query_one form "[data-hito-exercise-select]" with
477
| None -> Js._true
478
| Some node -> (
479
match Js.Opt.to_option (Dom_html.CoerceTo.select node) with
480
| None -> Js._true
481
| Some select ->
482
let slot = Js.to_string select##.value in
483
Dom.preventDefault event;
484
Lwt.async (fun () -> get ~workout:true (action ^ "?slot=" ^ slot));
485
Js._false))
486
487
let () =
488
Dom_html.addEventListener document Dom_html.Event.click
489
(Dom_html.handler link_click)
490
Js._false
491
|> ignore;
492
Dom_html.addEventListener document Dom_html.Event.submit
493
(Dom_html.handler form_submit)
494
Js._false
495
|> ignore;
496
Dom_html.addEventListener document Dom_html.Event.click
497
(Dom_html.handler feedback_click)
498
Js._false
499
|> ignore;
500
Dom_html.addEventListener document Dom_html.Event.click
501
(Dom_html.handler dialog_click)
502
Js._false
503
|> ignore;
504
Dom_html.addEventListener document Dom_html.Event.click
505
(Dom_html.handler feedback_choice)
506
Js._false
507
|> ignore;
508
Dom_html.addEventListener document Dom_html.Event.change
509
(Dom_html.handler exercise_change)
510
Js._false
511
|> ignore;
512
Dom_html.addEventListener document Dom_html.Event.click
513
(Dom_html.handler confirm_click)
514
Js._false
515
|> ignore;
516
Dom_html.addEventListener Dom_html.window Dom_html.Event.popstate
517
(Dom_html.handler popstate)
518
Js._false
519
|> ignore;
520
(* Background tabs throttle [setInterval], so the elapsed count can lag while
521
the tab is hidden. Tick once the moment the tab is shown again, so the
522
timer catches up at once rather than after the next throttled interval. *)
523
Dom_html.addEventListener document
524
(Dom_html.Event.make "visibilitychange")
525
(Dom_html.handler (fun _ ->
526
tick ();
527
Js._true))
528
Js._false
529
|> ignore;
530
(* Mark the document as script-enhanced so CSS can hide no-JS fallbacks such
531
as the exercise-selector submit button. Set only once the client runs, so
532
a no-JS visitor keeps every fallback. *)
533
document##.documentElement##setAttribute
534
(Js.string "data-hito-js") (Js.string "on");
535
(* Retry startup after the document is ready. This is idempotent because
536
[start_timer] clears any prior interval before it reads the current timer. *)
537
Dom_html.addEventListener document Dom_html.Event.domContentLoaded
538
(Dom_html.handler (fun _ ->
539
start_timer ();
540
Js._true))
541
Js._false
542
|> ignore;
543
start_timer ();
544
dismiss_toast ()
545