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 [])