open Dream_html open Page_kit open Page_shell (* HD1-grounded self-reports, each relative to the trainee's usual baseline. *) let feedback_factors = [ ("motivation", "Motivation"); ("difficulty", "Perceived difficulty") ] (* One factor: three radio buttons styled as inline buttons, none preselected. The group is named by its factor, so leaving it untouched reports nothing for that factor. *) let feedback_level_buttons field label = let choice code text = tag "label" [ class_ "feedback-choice" ] [ void "input" [ type_ "radio"; Dream_html.string_attr "name" "%s" field; Dream_html.string_attr "value" "%s" code; Dream_html.string_attr "id" "%s-%s" field code; ]; tag "span" [] [ txt "%s" text ]; ] in tag "div" [ class_ "feedback-group" ] [ tag "p" [ class_ "feedback-group-label" ] [ txt "%s" label ]; tag "div" [ class_ "feedback-buttons"; Dream_html.string_attr "role" "group"; Dream_html.string_attr "aria-label" "%s" label; ] [ choice "worse" "Worse than usual"; choice "usual" "As usual"; choice "better" "Better than usual"; ]; ] (* Every factor on one screen, one submit. A factor left unselected is simply absent from the post, so a partial report needs no Skip. *) let feedback_form request = tag "form" [ action Routes.feedback; post_form; class_ "feedback-form"; Dream_html.attr "data-hito-feedback-form"; Dream_html.attr "data-hito-app-form"; ] [ tag "fieldset" [] ([ Dream_html.csrf_tag request ] @ List.map (fun (field, label) -> feedback_level_buttons field label) feedback_factors @ [ tag "div" [ class_ "button-group" ] [ tag "button" [ type_ "submit" ] [ txt "Submit feedback" ]; tag "a" [ href Routes.logbook; class_ "button secondary"; Dream_html.attr "data-hito-feedback-dismiss"; ] [ txt "Cancel" ]; ]; ]); ] let feedback_modal request ~open_ = tag "dialog" ([ class_ "feedback-modal"; Dream_html.attr "data-hito-feedback-modal" ] @ if open_ then [ Dream_html.attr "open" ] else []) [ tag "div" [ class_ "feedback-modal-content" ] [ tag "h2" [] [ txt "How are you feeling?" ]; feedback_form request ]; ] let delete_record_dialog request (record : View_model.Recorded_workout.t) = tag "dialog" [ class_ "confirm-dialog"; Dream_html.attr "open" ] [ tag "h2" [] [ txt "Delete this workout log?" ]; tag "p" [] [ txt "Delete %s. This cannot be undone." record.workout_name ]; tag "div" [ class_ "button-group" ] [ tag "form" [ action Routes.delete_record record.id; post_form; class_ "delete-record-form"; ] [ Dream_html.csrf_tag request; tag "button" [ type_ "submit"; class_ "danger" ] [ txt "Delete workout" ]; ]; tag "a" [ href Routes.logbook; class_ "button secondary"; Dream_html.attr "data-hito-confirm-dismiss"; ] [ txt "Cancel" ]; ]; ] (* An inline coloured delta for one metric: green for an increase, oxblood for a decrease, following the app's progress palette. [None] when there is no prior value or the value is unchanged, so no parenthetical is shown. *) let duration_text ~seconds ~previous_seconds = match (seconds, previous_seconds) with | None, _ -> None | Some seconds, None -> Some (Printf.sprintf "%d min (—)" (seconds / 60)) | Some seconds, Some previous_seconds -> let delta_seconds = seconds - previous_seconds in let value = Printf.sprintf "%d min" (seconds / 60) in let delta_minutes = float_of_int delta_seconds /. 60. in Some (Printf.sprintf "%s (%+.1f min)" value delta_minutes) let format_load load = Printf.sprintf "%g" load let format_reps reps = string_of_int reps let delta_value ~previous ~current = let delta = current -. previous in let class_name, value = if Float.equal delta 0. then ("comparison-delta", "—") else if delta > 0. then ("comparison-delta delta-up", Printf.sprintf "%+g" delta) else ("comparison-delta delta-down", Printf.sprintf "%+g" delta) in (class_name, value) let value_cell class_name value = tag "td" [ class_ "%s" class_name ] [ txt "%s" value ] let delta_cell ~previous ~current = let class_name, value = delta_value ~previous ~current in tag "td" [ class_ "%s" class_name ] [ txt "%s" value ] let comparison_rows (row : View_model.Exercise_comparison.t) = let exercise_cell = tag "th" [ Dream_html.string_attr "scope" "row"; class_ "evidence-exercise" ] [ txt "%s" row.exercise_name ] in [ tag "tr" [] [ exercise_cell; value_cell "observation-current" (format_load row.current_load); delta_cell ~previous:(Option.value ~default:row.current_load row.previous_load) ~current:row.current_load; value_cell "observation-current" (format_reps row.current_reps); delta_cell ~previous: (float_of_int (Option.value ~default:row.current_reps row.previous_reps)) ~current:(float_of_int row.current_reps); ]; ] let workout_evidence_table (entry : View_model.Recorded_workout.t) groups = let caption = [ tag "strong" [ class_ "workout-evidence-caption-name" ] [ txt "%s" entry.workout_name ]; tag "span" [ class_ "workout-evidence-caption-status" ] [ txt "Performed on %s" entry.performed_on ]; ] in Page_kit.workout_evidence_table ~caption_class:"workout-evidence-caption" ~caption (List.map (fun (group : View_model.Stimulus_group.t) -> tag "tbody" [] (List.concat_map comparison_rows group.rows)) groups) let calendar_month_name month = List.nth [ "January"; "February"; "March"; "April"; "May"; "June"; "July"; "August"; "September"; "October"; "November"; "December"; ] (month - 1) let workout_metadata (entry : View_model.Recorded_workout.t) = let duration_item = match duration_text ~seconds:entry.duration_seconds ~previous_seconds:entry.duration_previous_seconds with | None -> [] | Some value -> [ tag "dt" [] [ txt "Duration" ]; tag "dd" [] [ txt "%s" value ] ] in let next_item = Option.to_list (Option.map (fun next -> [ tag "dt" [] [ txt "Next planned" ]; tag "dd" [] [ txt "%s" next ] ]) entry.next_scheduled) |> List.concat in tag "dl" [ class_ "workout-metadata" ] (duration_item @ [ tag "dt" [] [ txt "Performed" ]; tag "dd" [] [ txt "%s" entry.performed_ago ]; ] @ next_item) let selected_workout_card (entry : View_model.Recorded_workout.t) = let performance = if entry.groups = [] then [ tag "p" [] [ txt "No exercises recorded" ] ] else [ workout_evidence_table entry entry.groups ] in let actions = if entry.imported then [] else [ tag "div" [ class_ "logbook-entry-actions" ] [ tag "a" [ href Routes.record entry.id; class_ "button secondary"; Dream_html.attr "data-hito-app-link"; ] [ txt "Edit" ]; tag "a" [ Dream_html.string_attr "href" "/logbook?delete=%s" entry.id; class_ "button outline danger"; ] [ txt "Delete" ]; ]; ] in let imported = if entry.imported then [ tag "span" [ class_ "logbook-import-pill" ] [ txt "Imported" ] ] else [] in tag "article" [ class_ "selected-workout" ] (imported @ performance @ [ workout_metadata entry ] @ actions) let selected_upcoming_card = upcoming_workout_card let selected_date_panel (selected : View_model.Selected_date.t) = let completed = List.map selected_workout_card selected.completed in let planned = Option.to_list (Option.map selected_upcoming_card selected.upcoming) in let content = if completed = [] && planned = [] then [ tag "p" [] [ txt "No workout was recorded or planned on this day." ] ] else completed @ planned in tag "section" [ class_ "selected-day" ] content (* The month calendar above the selected-day panel. Days that carry a workout are highlighted; today is marked. A header row holds a previous arrow, the month and year, a Today link, and a next arrow. The arrows page the query. *) let calendar_grid (cal : View_model.Calendar.t) = let cell = function | View_model.Calendar.Blank -> tag "td" [ class_ "calendar-blank" ] [] | View_model.Calendar.Day d -> let classes = "calendar-day" ^ (if d.workout then " calendar-workout" else "") ^ (if Option.is_some d.scheduled_workout then " calendar-planned" else "") ^ (if d.selected then " calendar-selected" else "") ^ if d.today then " calendar-today" else "" in let aria_label = match (d.workout, d.scheduled_workout, d.today) with | true, Some name, true -> Some (Printf.sprintf "%d: completed, planned %s, today" d.day_of_month name) | true, Some name, false -> Some (Printf.sprintf "%d: completed, planned %s" d.day_of_month name) | true, None, true -> Some (Printf.sprintf "%d: completed workout, today" d.day_of_month) | true, None, false -> Some (Printf.sprintf "%d: completed workout" d.day_of_month) | false, Some name, true -> Some (Printf.sprintf "%d: planned %s, today" d.day_of_month name) | false, Some name, false -> Some (Printf.sprintf "%d: planned %s" d.day_of_month name) | false, None, true -> Some (Printf.sprintf "%d: today" d.day_of_month) | false, None, false -> None in let title = Option.map (fun name -> Printf.sprintf "Planned: %s" name) d.scheduled_workout in let href = Printf.sprintf "/logbook?year=%d&month=%d&day=%d" cal.selected_year cal.selected_month d.day_of_month in tag "td" ([ Dream_html.string_attr "class" "%s" classes ] @ Option.to_list (Option.map (Dream_html.string_attr "aria-label" "%s") aria_label) @ Option.to_list (Option.map (Dream_html.string_attr "title" "%s") title)) [ tag "a" [ Dream_html.string_attr "href" "%s" href; class_ "calendar-day-link"; Dream_html.attr "data-hito-app-link"; Dream_html.string_attr "aria-label" "Select day %d" d.day_of_month; ] [ txt "%d" d.day_of_month ]; ] in let prev_month = if cal.selected_month = 1 then (cal.selected_year - 1, 12) else (cal.selected_year, cal.selected_month - 1) in let next_month = if cal.selected_month = 12 then (cal.selected_year + 1, 1) else (cal.selected_year, cal.selected_month + 1) in let arrow ~label (y, m) rel = tag "a" [ Dream_html.string_attr "href" "/logbook?year=%d&month=%d" y m; class_ "calendar-arrow"; Dream_html.attr "data-hito-app-link"; Dream_html.string_attr "aria-label" "%s" rel; ] [ txt "%s" label ] in (* Header: previous, the month and year, a Today link that returns to the current month, next. *) tag "section" [ class_ "logbook-calendar"; Dream_html.string_attr "aria-labelledby" "calendar-heading"; ] [ tag "h2" [ id "calendar-heading"; class_ "sr-only" ] [ txt "Calendar" ]; tag "div" [ class_ "calendar" ] [ tag "nav" [ class_ "calendar-header"; Dream_html.string_attr "aria-label" "Calendar navigation"; ] [ arrow ~label:"‹" prev_month "Previous month"; tag "span" [ class_ "calendar-month" ] [ txt "%s %d" (calendar_month_name cal.selected_month) cal.selected_year; ]; tag "a" [ href Routes.logbook; class_ "calendar-today-link"; Dream_html.attr "data-hito-app-link"; ] [ txt "Today" ]; arrow ~label:"›" next_month "Next month"; ]; tag "table" [ class_ "calendar-grid" ] [ tag "thead" [] [ tag "tr" [] (List.map (fun h -> tag "th" [ Dream_html.string_attr "scope" "col" ] [ txt "%s" h ]) cal.weekday_headings); ]; tag "tbody" [] (List.map (fun week -> tag "tr" [] (List.map cell week)) cal.weeks); ]; tag "div" [ class_ "calendar-legend"; Dream_html.string_attr "role" "group"; Dream_html.string_attr "aria-label" "Calendar key"; ] [ tag "span" [ class_ "calendar-legend-item" ] [ tag "span" [ class_ "calendar-legend-swatch calendar-legend-workout" ] []; txt "Completed"; ]; tag "span" [ class_ "calendar-legend-item" ] [ tag "span" [ class_ "calendar-legend-swatch calendar-legend-planned" ] []; txt "Planned"; ]; tag "span" [ class_ "calendar-legend-item" ] [ tag "span" [ class_ "calendar-legend-swatch calendar-legend-today" ] []; txt "Today"; ]; ]; tag "p" [ class_ "calendar-note" ] [ txt "Planned dates follow the recovery time in your routine. Hover \ a planned day to see the workout."; ]; ]; ] let logbook_tabs ~active_tab = link_tabs ~aria_label:"Logbook views" ~active:active_tab [ ("calendar", "Calendar", "/logbook?tab=calendar"); ("progress", "Progress", "/logbook?tab=progress"); ] let progress_legend () = tag "div" [ class_ "chart-legend" ] [ tag "span" [ class_ "chart-legend-item weight" ] [ txt "Weight (kg), left axis" ]; tag "span" [ class_ "chart-legend-item reps" ] [ txt "Reps, right axis" ]; ] let progress_chart_section (chart : View_model.Progress_page.chart) = tag "section" [ class_ "progress-chart-section" ] [ tag "h2" [] [ txt "%s" chart.exercise_name ]; progress_legend (); Svg_chart.render chart; ] let progress_content charts = match charts with | [] -> [ tag "p" [] [ txt "No exercise has enough history to chart yet. Log or import at \ least two performances of an exercise to see its progress."; ]; ] | charts -> List.map progress_chart_section charts let logbook request ?workout_started ~viewer ?calendar ?selected_date ?(suggest_feedback = false) ?(feedback_open = false) ?delete_record ?(tab = "calendar") ?progress_charts () = let active_tab = if String.equal tab "progress" then "progress" else "calendar" in let content = match active_tab with | "calendar" -> (match calendar with Some cal -> [ calendar_grid cal ] | None -> []) @ [ (match selected_date with | Some selected -> selected_date_panel selected | None -> tag "section" [ class_ "selected-day empty-selected-day" ] [ tag "p" [] [ txt "Select a day to view its workout." ] ]); ] | "progress" -> Option.value progress_charts ~default:[] |> progress_content | _ -> [] in html_page ~viewer ~request ~active:"logbook" ?workout_started "Logbook" ([ logbook_tabs ~active_tab; (if suggest_feedback then tag "p" [ class_ "eyebrow" ] [ txt "Workout saved — add feedback while it is fresh." ] else txt ""); ] @ content @ [ feedback_modal request ~open_:feedback_open ] @ match delete_record with | Some record -> [ delete_record_dialog request record ] | None -> [])