open Dream_html open Page_kit open Page_shell let extension_select ?(selected = "") ~input_id () = let option (code : string) label = let attrs = [ Dream_html.string_attr "value" "%s" code ] in let attrs = if String.equal code selected then Dream_html.attr "selected" :: attrs else attrs in tag "option" attrs [ txt "%s" label ] in tag "select" [ name "extension"; Dream_html.string_attr "id" "%s" input_id ] [ option "" "to positive failure"; option "forced" "then forced reps"; option "negatives" "then negatives"; option "static" "then a static hold"; ] (* The extension amount counts reps or seconds. Both words are rendered, and the CSS shows the one that matches the selected ending. *) let reps_or_seconds ~reps ~seconds = [ tag "span" [ class_ "amount-label-reps" ] [ txt "%s" reps ]; tag "span" [ class_ "amount-label-sep" ] [ txt " / " ]; tag "span" [ class_ "amount-label-seconds" ] [ txt "%s" seconds ]; ] (* One movement is one item in the prescribed sequence. Its name, load, and reps stay together, so a responsive layout cannot mix two movements. *) let movement_group ~name ~load_id ~load_field ~reps_id ~reps_field ?load_value ?reps_value ?previous_load ?previous_reps ~reps_label () = let number_field ~additional_class ~input_id ~field ~value ~step_ ~label ~previous ~unit = let input = void "input" ([ type_ "number"; Dream_html.string_attr "name" "%s" field; Dream_html.string_attr "id" "%s" input_id; step "%s" step_; required; ] @ match value with | None -> [] | Some v -> [ Dream_html.string_attr "value" "%s" v ]) in let group_class = match previous with | None -> "input-group" | Some _ -> "input-group input-group-with-previous" in let previous_control = match previous with | None -> [] | Some p -> [ tag "span" [ class_ "exercise-previous" ] [ txt "%s" p ] ] in let control = tag "div" [ Dream_html.string_attr "class" "%s" group_class ] (previous_control @ [ input; tag "span" [ class_ "input-unit" ] [ txt "%s" unit ] ]) in tag "div" [ class_ "field field-number%s" additional_class ] [ tag "label" [ Dream_html.string_attr "for" "%s" input_id; class_ "sr-only" ] [ txt "%s" label ]; control; ] in tag "li" [ class_ "movement-group" ] [ tag "fieldset" [ class_ "movement-fieldset" ] [ tag "legend" [] [ txt "%s" name ]; tag "div" [ class_ "movement-controls" ] [ number_field ~additional_class:"" ~input_id:load_id ~field:load_field ~value:load_value ~step_:"0.5" ~label:"Load (kg)" ~previous:previous_load ~unit:"kg"; number_field ~additional_class:" field-after-load" ~input_id:reps_id ~field:reps_field ~value:reps_value ~step_:"1" ~label:reps_label ~previous:previous_reps ~unit:"reps"; ]; ]; ] (* A stimulus records one movement or a prescribed pre-exhaust sequence. Its ending belongs to the whole stimulus, not to its final movement. *) let form_for_stimulus request ~action_path ~slot ~(movements : View_model.Workout.movement list) ~selected_extension ~selected_extension_amount ?(enhanced = false) ?(submit = "Record") ?(correction = false) () = let field_id field = Printf.sprintf "slot-%d-%s" slot field in let selected = selected_extension in let movement_of (m : View_model.Workout.movement) = movement_group ~name:m.name ~load_id:(field_id m.load_field) ~load_field:m.load_field ~reps_id:(field_id m.reps_field) ~reps_field:m.reps_field ?load_value:m.load_value ?reps_value:m.reps_value ?previous_load:m.previous_load ?previous_reps:m.previous_reps ~reps_label:m.reps_label () in let set_ending = tag "fieldset" [ class_ "set-ending" ] [ tag "legend" [] [ txt "Set ending" ]; tag "div" [ class_ "set-ending-controls" ] [ tag "div" [ class_ "field field-ending" ] [ tag "label" [ Dream_html.string_attr "for" "%s" (field_id "extension"); class_ "sr-only"; ] [ txt "Ending" ]; extension_select ~selected ~input_id:(field_id "extension") (); ]; tag "div" [ class_ "field field-extension-amount field-after-ending" ] [ tag "label" [ Dream_html.string_attr "for" "%s" (field_id "extension_amount"); class_ "sr-only"; ] (reps_or_seconds ~reps:"Extension reps" ~seconds:"Hold seconds"); tag "div" [ class_ "input-group" ] [ void "input" [ type_ "number"; name "extension_amount"; Dream_html.string_attr "id" "%s" (field_id "extension_amount"); Dream_html.string_attr "min" "1"; step "1"; Dream_html.string_attr "value" "%s" selected_extension_amount; ]; tag "span" [ class_ "input-unit" ] (reps_or_seconds ~reps:"reps" ~seconds:"seconds"); ]; ]; ]; ] in let action_control = if correction then tag "div" [ class_ "correction-actions" ] [ void "input" [ type_ "submit"; Dream_html.string_attr "value" "%s" submit; Dream_html.attr "data-hito-correction-action"; ]; tag "button" [ type_ "reset"; class_ "secondary" ] [ txt "Reset" ]; ] else void "input" [ type_ "submit"; class_ "record-action"; Dream_html.string_attr "value" "%s" submit; ] in tag "form" ([ action_path; post_form ] @ (if enhanced then [ Dream_html.attr "data-hito-workout-form" ] else [ Dream_html.attr "data-hito-app-form" ]) @ if correction then [ Dream_html.attr "data-hito-correction-form" ] else [] ) [ Dream_html.csrf_tag request; tag "div" [ class_ "logging-form" ] [ tag "ol" [ class_ "movement-list" ] (List.map movement_of movements); set_ending; action_control; ]; ] let workout request ~viewer ?workout_started ~record_id ~active_slot (vm : View_model.Workout.t) = let enhanced = Option.is_none record_id in (* A live workout view is itself the workout in progress, so its own start drives the nav timer. A saved-record view is history: the timer, if any, belongs to a separate workout the handler reports through [workout_started]. *) let workout_started = if enhanced then Some vm.started_at_unix else workout_started in let record_action slot = match record_id with | None -> action Routes.workout_slot slot | Some record_id -> action Routes.record_slot record_id slot in let edit_action slot = match record_id with | None -> action Routes.workout_slot_edit slot | Some record_id -> action Routes.record_slot_edit record_id slot in (* The exercise accordion: one
per prescription slot, all sharing a [name], which the browser treats as an exclusive group — opening one closes the rest, with no script. The active slot is open; the handler picks it from [?slot=], so a record or correction reopens the slot it belongs to. The summary carries the slot's level-two heading: its number and exercise label, struck through once recorded. A recorded slot shows a pre-filled correction form (which replaces the slot rather than adding volume); an outstanding slot shows the record form. *) let slot_section (slot : View_model.Workout.slot) = let index = slot.index in let done_ = Option.is_some slot.recorded_summary in let body = match slot.recorded_summary with | Some _ -> [ form_for_stimulus request ~action_path:(edit_action index) ~slot:index ~movements:slot.movements ~selected_extension:slot.selected_extension ~selected_extension_amount:slot.selected_extension_amount ~enhanced ~submit:"Save correction" ~correction:true (); ] | None -> [ form_for_stimulus request ~action_path:(record_action index) ~slot:index ~movements:slot.movements ~selected_extension:slot.selected_extension ~selected_extension_amount:slot.selected_extension_amount ~enhanced (); ] in tag "details" ([ class_ "slot-accordion"; Dream_html.string_attr "name" "exercise"; Dream_html.string_attr "id" "slot-%d" index; ] @ (if index = active_slot then [ Dream_html.attr "open" ] else []) @ if done_ then [ Dream_html.string_attr "data-done" "true" ] else []) [ tag "summary" [] [ tag "h2" [ class_ "slot-heading" ] [ tag "span" [ class_ "slot-number" ] [ txt "%d." (index + 1) ]; tag "span" [ Dream_html.string_attr "class" "slot-label%s" (if done_ then " slot-label-done" else ""); ] [ txt "%s" slot.label ]; ]; ]; tag "div" [ class_ "slot-panel" ] [ tag "div" [ class_ "slot-panel-body" ] body ]; ] in let slots = [ tag "div" [ class_ "slot-accordions" ] (List.map slot_section vm.slots) ] in let complete_note = if vm.all_recorded then [ tag "p" [ class_ "done" ] [ txt "Everything prescribed has been recorded." ]; ] else [] in let finish_section = match record_id with | Some _ -> [] | None -> [ tag "div" [ Dream_html.string_attr "class" "button-group finish-actions%s" (if vm.all_recorded then " finish-actions-complete" else ""); ] [ tag "form" [ action Routes.finish_workout; post_form; Dream_html.attr "data-hito-app-form"; ] [ Dream_html.csrf_tag request; void "input" [ type_ "submit"; value "Finish workout" ]; ]; (* Cancel is a native POST form. Without script, it submits immediately. The client only opens the confirm dialog. Its acceptance submits this form as a full document navigation so the cancelled workout cannot leave a stale app shell behind. *) tag "form" [ action Routes.cancel_workout; post_form; Dream_html.attr "data-hito-confirm-form"; ] [ Dream_html.csrf_tag request; void "input" [ type_ "submit"; class_ "secondary"; value "Cancel logging"; Dream_html.attr "data-hito-confirm-cancel"; ]; ]; ]; (* The confirmation modal. Native dialog for built-in focus trapping and Escape-to-close. The client opens it. "Cancel workout" submits the cancel form, "Keep logging" closes it. *) tag "dialog" [ class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal"; ] [ tag "h2" [] [ txt "Cancel logging this workout?" ]; tag "p" [] [ txt "This action cannot be undone." ]; tag "div" [ class_ "button-group" ] [ tag "button" [ type_ "button"; Dream_html.attr "data-hito-confirm-accept"; ] [ txt "Cancel logging" ]; tag "button" [ type_ "button"; class_ "secondary"; Dream_html.attr "data-hito-confirm-dismiss"; ] [ txt "Continue logging" ]; ]; ]; ] in html_page ~viewer ~request ~active:"workout" ?workout_started vm.name ([ tag "h1" [] [ txt "%s" vm.name ]; tag "h2" [ class_ "workout-date" ] [ txt "%s" vm.date ]; ] @ slots @ complete_note @ finish_section @ if enhanced then [ tag "p" [ Dream_html.string_attr "aria-live" "polite"; Dream_html.attr "data-hito-workout-status"; ] []; ] else [])