open Dream_html
open Page_kit
open Page_shell
let choose_routine request ~workout_started ~viewer
~(routines : View_model.Routine_choice.t list) =
html_page ~viewer ~request ~active:"home" ?workout_started "Choose a routine"
([ tag "h1" [] [ txt "Routines" ] ]
@ List.map
(fun (choice : View_model.Routine_choice.t) ->
tag "form"
[
action Routes.select_routine choice.id;
post_form;
Dream_html.attr "data-hito-app-form";
]
[
Dream_html.csrf_tag request;
tag "fieldset" []
[
tag "legend" [] [ txt "%s" choice.name ];
tag "p" []
[ txt "%d workouts in the cycle." choice.workout_count ];
void "input" [ type_ "submit"; value "Use this routine" ];
];
])
routines)
(* Both begin forms post the same fields. Only the override flag differs. *)
let begin_fields request ~routine_id ~override =
[
Dream_html.csrf_tag request;
void "input"
[
type_ "hidden";
name "routine";
Dream_html.string_attr "value" "%s" routine_id;
];
void "input"
[
type_ "hidden";
name "override";
Dream_html.string_attr "value" "%s" (string_of_bool override);
];
]
let begin_form request ~routine_id ~override label =
tag "form"
[ action Routes.workout; post_form; Dream_html.attr "data-hito-app-form" ]
(begin_fields request ~routine_id ~override
@ [ void "input" [ type_ "submit"; value label ] ])
(* Beginning under override needs an explicit acknowledgement, so its submit is
gated behind a confirmation modal. Without script the form submits at once —
the doctrine's deviation stays possible. The modal only makes it deliberate.
The client reuses the shared confirm machinery: [data-hito-confirm-cancel]
opens the dialog, [data-hito-confirm-form] is the form its accept submits. *)
let override_begin request ~routine_id =
[
tag "form"
[
action Routes.workout;
post_form;
Dream_html.attr "data-hito-app-form";
Dream_html.attr "data-hito-confirm-form";
]
(begin_fields request ~routine_id ~override:true
@ [
void "input"
[
type_ "submit";
class_ "outline";
value "Begin under override";
Dream_html.attr "data-hito-confirm-cancel";
];
]);
tag "dialog"
[ class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal" ]
[
tag "h2" [] [ txt "Begin before recovery is complete?" ];
tag "p" []
[
txt
"Heavy Duty grows muscle during recovery, not in the gym. \
Training early can reduce it. The app records this as an \
override.";
];
tag "div"
[ class_ "button-group" ]
[
tag "button"
[ type_ "button"; Dream_html.attr "data-hito-confirm-accept" ]
[ txt "Begin under override" ];
tag "button"
[
type_ "button";
class_ "secondary";
Dream_html.attr "data-hito-confirm-dismiss";
]
[ txt "Wait to recover" ];
];
];
]
let home request ~viewer ~home:(vm : View_model.Home.t) =
let status, gate =
match vm.gate with
| View_model.Home.Ready ->
( None,
[
begin_form request ~routine_id:vm.routine_id ~override:false
"Begin next workout";
] )
| View_model.Home.Recovering { status } ->
( Some status,
[
tag "div"
[ class_ "warn" ]
([
tag "p" []
[
txt "Heavy Duty requires recovery before the next workout.";
];
]
@ override_begin request ~routine_id:vm.routine_id);
] )
in
html_page ~viewer ~request ~active:"home" "Home"
([
tag "h1" [] [ txt "Next Workout" ]; upcoming_workout_card vm.next_workout;
]
@ Option.to_list
(Option.map (fun text -> tag "p" [] [ txt "%s" text ]) status)
@ gate)
(* Home while a workout is in progress. Rather than the recovery gate or the
routine chooser, Home shows a single card: the workout is under way and one
tap returns to it. The middle navigation action already reads "Current
workout" here, and this card is the matching Home affordance. *)
let workout_in_progress request ~viewer ~workout_started ~workout_name =
html_page ~viewer ~request ~active:"home" ~workout_started "Home"
[
tag "a"
[
class_ "in-progress";
href Routes.workout;
Dream_html.attr "data-hito-app-link";
]
[
tag "p" [ class_ "eyebrow" ] [ txt "Workout in progress" ];
tag "h1" [] [ txt "%s" workout_name ];
tag "p" [] [ txt "Return to the current workout to keep logging." ];
];
]
(* One workout stop on the timeline. The workout is a native [details], so it
works without script and needs no [name]: stops open independently and the
trainee can compare workouts. Only the next workout starts open. The rest
stays outside the [details], so a closed timeline still shows the gating. *)
let workout_stop (workout : View_model.Routine.workout) =
let next, recovery =
match workout.position with
| View_model.Routine.Next { recovery } -> (true, recovery)
| View_model.Routine.Elsewhere -> (false, None)
in
tag "li"
([ class_ "routine-stop" ]
@ if next then [ Dream_html.string_attr "aria-current" "step" ] else [])
[
tag "details"
([ class_ "routine-workout" ]
@ if next then [ Dream_html.attr "open" ] else [])
[
tag "summary" []
([ tag "h2" [ class_ "routine-day" ] [ txt "%s" workout.name ] ]
@ (if next then
[ tag "span" [ class_ "routine-next" ] [ txt "Next" ] ]
else [])
@
match recovery with
| Some status ->
[ tag "span" [ class_ "routine-recovery" ] [ txt "%s" status ] ]
| None -> []);
(* The panel clips the content while the unfold animates. *)
tag "div"
[ class_ "routine-panel" ]
[
tag "ol"
[ class_ "routine-exercises" ]
(List.map
(fun (exercise : View_model.Routine.exercise) ->
tag "li" []
[
tag "span"
[ class_ "routine-exercise" ]
[ txt "%s" exercise.name ];
])
workout.exercises);
];
];
tag "p" [ class_ "routine-rest" ] [ txt "%s" workout.rest_after ];
]
let routine request ?workout_started ~viewer (vm : View_model.Routine.t) =
html_page ~viewer ~request ~active:"routine" ?workout_started "Routine"
[
tag "h1" [] [ txt "%s" vm.name ];
tag "p" []
[
tag "a"
[ href Routes.routines; Dream_html.attr "data-hito-app-link" ]
[ txt "Browse routines" ];
];
tag "ol" [ class_ "routine-timeline" ] (List.map workout_stop vm.workouts);
]