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