open Js_of_ocaml open Lwt.Infix let workout_selector = "[data-hito-workout-content]" let app_selector = "[data-hito-app-shell]" let document = Dom_html.document let request_number = ref 0 let query_one root selector = match Js.Opt.to_option (root##querySelector (Js.string selector)) with | None -> None | Some node -> Js.Opt.to_option (Dom_html.CoerceTo.element node) let closest selector element = match Js.Opt.to_option (element##closest (Js.string selector)) with | None -> None | Some node -> Js.Opt.to_option (Dom_html.CoerceTo.element node) let set_busy busy = match query_one document app_selector with | Some shell -> shell##setAttribute (Js.string "aria-busy") (Js.string (if busy then "true" else "false")) | None -> () let fallback url = Dom_html.window##.location##assign (Js.string url) let announce message = match query_one document "[data-hito-workout-status]" with | Some status -> status##.textContent := Js.some (Js.string message) | None -> () (* The early-workout toast. The server renders it visible so a no-JS visitor still reads it. With script it is a transient notice: mark it shown, then remove it after three seconds (WCAG 2.2.1 gives users time to read a brief status). Idempotent: a toast already dismissed carries [data-hito-toast-done] and is left alone, so a content swap never re-arms the same notice. *) let dismiss_toast () = match query_one document "[data-hito-toast]" with | None -> () | Some toast -> ( match Js.Opt.to_option (toast##getAttribute (Js.string "data-hito-toast-done")) with | Some _ -> () | None -> toast##setAttribute (Js.string "data-hito-toast-done") (Js.string ""); ignore (Dom_html.window##setTimeout (Js.wrap_callback (fun () -> match Js.Opt.to_option toast##.parentNode with | Some parent -> Dom.removeChild parent toast | None -> ())) (Js.number_of_float 3000.))) (* The workout timer. The sticky bar carries the workout's start time as an epoch in [data-started]. The elapsed time is computed from it, so a content swap that re-renders the bar never resets the count. A single interval runs at a time: [start_timer] clears the previous one before it reads the bar the latest render produced. When no bar is present, the timer stops. *) let timer_interval = ref None let format_elapsed seconds = let seconds = if seconds < 0 then 0 else seconds in let minutes = seconds / 60 in let secs = seconds mod 60 in Printf.sprintf "%d:%02d" minutes secs let tick () = match query_one document "[data-hito-workout-timer]" with | None -> () | Some bar -> ( match Js.Opt.to_option (bar##getAttribute (Js.string "data-started")) with | None -> () | Some started -> ( (* Epoch seconds exceed js_of_ocaml's 31-bit [int] range, so the start and the current time are kept as floats. Only the small elapsed difference is narrowed to an [int]. Doing the subtraction in [int] would overflow and yield a wrong count. *) let started = try float_of_string (String.trim (Js.to_string started)) with _ -> 0. in let now = Js.to_float (new%js Js.date_now)##getTime /. 1000. in let elapsed = int_of_float (now -. started) in match query_one document "[data-hito-workout-timer-value]" with | None -> () | Some value -> value##.textContent := Js.some (Js.string (format_elapsed elapsed)))) let start_timer () = (match !timer_interval with | Some id -> Dom_html.window##clearInterval id; timer_interval := None | None -> ()); match query_one document "[data-hito-workout-timer]" with | None -> () | Some _ -> tick (); let id = Dom_html.window##setInterval (Js.wrap_callback (fun () -> tick ())) (Js.number_of_float 1000.) in timer_interval := Some id let feedback_modal () = query_one document "[data-hito-feedback-modal]" let open_feedback_modal () = match feedback_modal () with | None -> () | Some modal -> modal##removeAttribute (Js.string "open"); ignore (Js.Unsafe.meth_call modal "showModal" [||]) let close_feedback_modal () = match feedback_modal () with | None -> () | Some modal -> ignore (Js.Unsafe.meth_call modal "close" [||]) (* A generic modal keyed by id: [data-hito-dialog-open="id"] opens the dialog with that id, and [data-hito-dialog-close] closes its nearest dialog. Used by the profile page so username and password edits open in a modal, while a no-script client still shows the same server-rendered form. *) let open_dialog id = match query_one document (Printf.sprintf "dialog#%s" id) with | None -> () | Some modal -> modal##removeAttribute (Js.string "open"); ignore (Js.Unsafe.meth_call modal "showModal" [||]) let dialog_click event = let target = Dom_html.eventTarget event in match closest "[data-hito-dialog-open]" target with | Some trigger -> ( match Js.Opt.to_option (trigger##getAttribute (Js.string "data-hito-dialog-open")) with | None -> Js._true | Some id -> Dom.preventDefault event; open_dialog (Js.to_string id); Js._false) | None -> ( match closest "[data-hito-dialog-close]" target with | None -> Js._true | Some close -> ( match closest "dialog" close with | None -> Js._true | Some modal -> Dom.preventDefault event; ignore (Js.Unsafe.meth_call modal "close" [||]); Js._false)) let open_marked_feedback_modal () = match feedback_modal () with | Some modal when Js.to_bool (modal##hasAttribute (Js.string "open")) -> open_feedback_modal () | _ -> () let replace selector html = let next = Dom_html.createDiv document in next##.innerHTML := Js.string html; match (query_one document selector, query_one next selector) with | Some current, Some replacement -> let parent = Js.Opt.get current##.parentNode (fun () -> assert false) in Dom.replaceChild parent replacement current; (match Js.Opt.to_option (replacement##getAttribute (Js.string "data-hito-page-title")) with | Some title -> document##.title := title | None -> ()); (match query_one document (selector ^ " h1, " ^ selector ^ " h2") with | Some heading -> heading##setAttribute (Js.string "tabindex") (Js.string "-1"); heading##focus | None -> ()); (* No "Page updated" announcement: moving focus to the swapped-in heading already tells assistive tech the content changed, so a status message would be redundant noise. Transient "Loading"/"Saving" still clear when the swap lands, because the fresh status node starts empty. *) start_timer (); dismiss_toast (); open_marked_feedback_modal (); true | _ -> false let replace_app = replace app_selector let replace_workout = replace workout_selector let push_url url = Dom_html.window##.history##pushState Js.null (Js.string "") (Js.some (Js.string url)) let get ?(push = false) ?(workout = false) url = incr request_number; let request = !request_number in set_busy true; announce "Loading"; Js_of_ocaml_lwt.XmlHttpRequest.perform_raw_url ~with_credentials:true url >>= fun response -> let replace = if workout then replace_workout else replace_app in if request <> !request_number then Lwt.return_unit else ( set_busy false; if response.code >= 200 && response.code < 300 && replace response.content then ( if push then push_url url; Lwt.return_unit) else ( fallback url; Lwt.return_unit)) let modified event = Js.to_bool event##.metaKey || Js.to_bool event##.ctrlKey || Js.to_bool event##.shiftKey || Js.to_bool event##.altKey let href link = Js.Opt.to_option (link##getAttribute (Js.string "href")) |> Option.map Js.to_string let link_click event = if event##.button <> 0 || modified event then Js._true else match closest "a[data-hito-app-link], a[data-hito-workout-link]" (Dom_html.eventTarget event) with | None -> Js._true | Some link -> ( match href link with | None -> Js._true | Some url -> Dom.preventDefault event; let workout = Js.to_bool (link##hasAttribute (Js.string "data-hito-workout-link")) in Lwt.async (fun () -> get ~push:true ~workout url); Js._false) let slot_from_action action = match List.rev (String.split_on_char '/' action) with | "edit" :: slot :: _ | slot :: _ -> slot | [] -> "" let form_submit event = match closest "form[data-hito-app-form], form[data-hito-workout-form]" (Dom_html.eventTarget event) with | None -> Js._true | Some element -> ( match Js.Opt.to_option (Dom_html.CoerceTo.form element) with | None -> Js._true | Some form when not (Js.to_bool form##checkValidity) -> Js._true | Some form -> let action = match Js.Opt.to_option (form##getAttribute (Js.string "action")) with | Some action -> Js.to_string action | None -> "/" in incr request_number; let request = !request_number in let workout = Js.to_bool (form##hasAttribute (Js.string "data-hito-workout-form")) in let feedback = Js.to_bool (form##hasAttribute (Js.string "data-hito-feedback-form")) in let submit = match Js.Opt.to_option (form##querySelector (Js.string "input[type=submit]")) with | None -> None | Some input -> Js.Opt.to_option (Dom_html.CoerceTo.input input) in Dom.preventDefault event; Option.iter (fun input -> input##.disabled := Js._true) submit; set_busy true; announce "Saving"; (* Dream.form accepts URL-encoded forms. FormData produces a multipart request, which requires Dream.multipart instead. Keep this shared client path URL-encoded because all app forms use ordinary fields, including textareas. *) let contents = Form.get_form_contents form |> List.map (fun (name, value) -> (name, `String (Js.string value))) in Lwt.async (fun () -> Js_of_ocaml_lwt.XmlHttpRequest.perform_raw_url ~with_credentials:true ~override_method:`POST ~contents:(`POST_form contents) action >>= fun response -> if request <> !request_number then Lwt.return_unit else ( set_busy false; Option.iter (fun input -> input##.disabled := Js._false) submit; let replace = if workout then replace_workout else replace_app in if response.code = 400 && replace response.content then Lwt.return_unit else if workout && response.code >= 200 && response.code < 300 then get ~workout:true ("/workout?slot=" ^ slot_from_action action) else if response.code < 500 && replace response.content then ( if response.code >= 200 && response.code < 300 then push_url response.url; Lwt.return_unit) else ( fallback (if feedback then "/logbook" else action); Lwt.return_unit))); Js._false) let popstate _ = Lwt.async (fun () -> get (Js.to_string Dom_html.window##.location##.href)); Js._true (* The cancel confirmation modal. The cancel form submits directly without script. With script, its submit is intercepted only to open a native dialog. "Cancel workout" submits the native form as a full document POST. "Keep logging" and Escape close the dialog. The dialog and the pending form are found fresh on each click, so a content swap never leaves a stale reference. *) let confirm_modal () = query_one document "[data-hito-confirm-modal]" let close_modal () = match confirm_modal () with | None -> () | Some modal -> ignore (Js.Unsafe.meth_call modal "close" [||]) let open_modal () = match confirm_modal () with | None -> () | Some modal -> ignore (Js.Unsafe.meth_call modal "showModal" [||]) let pending_confirm_form () = match query_one document "[data-hito-confirm-form]" with | None -> None | Some element -> Js.Opt.to_option (Dom_html.CoerceTo.form element) let confirm_click event = let target = Dom_html.eventTarget event in match closest "[data-hito-confirm-cancel]" target with | Some _ -> (* Opening the modal replaces immediate cancellation. *) Dom.preventDefault event; open_modal (); Js._false | None -> ( match closest "[data-hito-confirm-dismiss]" target with | Some _ -> Dom.preventDefault event; close_modal (); Js._false | None -> ( match closest "[data-hito-confirm-accept]" target with | Some _ -> ( Dom.preventDefault event; close_modal (); match pending_confirm_form () with | Some form -> ignore (Js.Unsafe.meth_call form "requestSubmit" [||]); Js._false | None -> Js._false) | None -> Js._true)) (* The feedback flow uses the same native dialog behavior as workout cancellation. The trigger remains a normal link, so no-script clients open the server-rendered modal page instead. *) let feedback_click event = let target = Dom_html.eventTarget event in match closest "[data-hito-feedback-open]" target with | Some _ -> Dom.preventDefault event; open_feedback_modal (); Js._false | None -> ( match closest "[data-hito-feedback-dismiss]" target with | Some _ -> Dom.preventDefault event; close_feedback_modal (); Js._false | None -> ( match closest "[data-hito-feedback-action]" target with | None -> Js._true | Some action -> ( match closest "form" action with | None -> Js._true | Some form -> ( match Js.Opt.to_option (form##getAttribute (Js.string "data-hito-feedback-pending")) with | Some _ -> Dom.preventDefault event; Js._false | None -> ( match Js.Opt.to_option (action##getAttribute (Js.string "value")) with | None -> Js._true | Some value -> ( match query_one form "[data-hito-feedback-action-value]" with | None -> Js._true | Some hidden -> form##setAttribute (Js.string "data-hito-feedback-pending") (Js.string ""); hidden##setAttribute (Js.string "value") value; Js._true)))))) (* Selecting a score advances to the next factor without a separate Next action. Handle click rather than change so selecting the current score again follows the same path. Defer the submit by one event-loop tick so the browser applies the radio value before form data is collected. *) let feedback_choice event = match closest "[data-hito-feedback-choice]" (Dom_html.eventTarget event) with | None -> Js._true | Some choice -> ( match closest "form" choice with | None -> Js._true | Some form -> ( match Js.Opt.to_option (form##getAttribute (Js.string "data-hito-feedback-pending")) with | Some _ -> Js._false | None -> ( match query_one form "[data-hito-feedback-action-value]" with | None -> Js._true | Some hidden -> form##setAttribute (Js.string "data-hito-feedback-pending") (Js.string ""); ignore (Dom_html.window##setTimeout (Js.wrap_callback (fun () -> hidden##setAttribute (Js.string "value") (Js.string "next"); ignore (Js.Unsafe.meth_call form "requestSubmit" [||]))) (Js.number_of_float 0.)); Js._true))) (* The exercise dropdown navigates the moment its selection changes. It lives in a GET form ([data-hito-exercise-form]) that submits to the workout path. The client turns a change into a workout-scoped content swap to [?slot=N], so no submit click is needed. Without script the form's submit button still works. *) let exercise_change event = match closest "[data-hito-exercise-form]" (Dom_html.eventTarget event) with | None -> Js._true | Some form -> ( let action = match Js.Opt.to_option (form##getAttribute (Js.string "action")) with | Some action -> Js.to_string action | None -> "/workout" in match query_one form "[data-hito-exercise-select]" with | None -> Js._true | Some node -> ( match Js.Opt.to_option (Dom_html.CoerceTo.select node) with | None -> Js._true | Some select -> let slot = Js.to_string select##.value in Dom.preventDefault event; Lwt.async (fun () -> get ~workout:true (action ^ "?slot=" ^ slot)); Js._false)) let () = Dom_html.addEventListener document Dom_html.Event.click (Dom_html.handler link_click) Js._false |> ignore; Dom_html.addEventListener document Dom_html.Event.submit (Dom_html.handler form_submit) Js._false |> ignore; Dom_html.addEventListener document Dom_html.Event.click (Dom_html.handler feedback_click) Js._false |> ignore; Dom_html.addEventListener document Dom_html.Event.click (Dom_html.handler dialog_click) Js._false |> ignore; Dom_html.addEventListener document Dom_html.Event.click (Dom_html.handler feedback_choice) Js._false |> ignore; Dom_html.addEventListener document Dom_html.Event.change (Dom_html.handler exercise_change) Js._false |> ignore; Dom_html.addEventListener document Dom_html.Event.click (Dom_html.handler confirm_click) Js._false |> ignore; Dom_html.addEventListener Dom_html.window Dom_html.Event.popstate (Dom_html.handler popstate) Js._false |> ignore; (* Background tabs throttle [setInterval], so the elapsed count can lag while the tab is hidden. Tick once the moment the tab is shown again, so the timer catches up at once rather than after the next throttled interval. *) Dom_html.addEventListener document (Dom_html.Event.make "visibilitychange") (Dom_html.handler (fun _ -> tick (); Js._true)) Js._false |> ignore; (* Mark the document as script-enhanced so CSS can hide no-JS fallbacks such as the exercise-selector submit button. Set only once the client runs, so a no-JS visitor keeps every fallback. *) document##.documentElement##setAttribute (Js.string "data-hito-js") (Js.string "on"); (* Retry startup after the document is ready. This is idempotent because [start_timer] clears any prior interval before it reads the current timer. *) Dom_html.addEventListener document Dom_html.Event.domContentLoaded (Dom_html.handler (fun _ -> start_timer (); Js._true)) Js._false |> ignore; start_timer (); dismiss_toast ()