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