View raw

1 open Dream_html 2 open Page_kit 3 open Page_shell 4 5 (* HD1-grounded self-reports, each relative to the trainee's usual baseline. *) 6 let feedback_factors = 7 [ ("motivation", "Motivation"); ("difficulty", "Perceived difficulty") ] 8 9 (* One factor: three radio buttons styled as inline buttons, none preselected. 10 The group is named by its factor, so leaving it untouched reports nothing for 11 that factor. *) 12 let feedback_level_buttons field label = 13 let choice code text = 14 tag "label" 15 [ class_ "feedback-choice" ] 16 [ 17 void "input" 18 [ 19 type_ "radio"; 20 Dream_html.string_attr "name" "%s" field; 21 Dream_html.string_attr "value" "%s" code; 22 Dream_html.string_attr "id" "%s-%s" field code; 23 ]; 24 tag "span" [] [ txt "%s" text ]; 25 ] 26 in 27 tag "div" 28 [ class_ "feedback-group" ] 29 [ 30 tag "p" [ class_ "feedback-group-label" ] [ txt "%s" label ]; 31 tag "div" 32 [ 33 class_ "feedback-buttons"; 34 Dream_html.string_attr "role" "group"; 35 Dream_html.string_attr "aria-label" "%s" label; 36 ] 37 [ 38 choice "worse" "Worse than usual"; 39 choice "usual" "As usual"; 40 choice "better" "Better than usual"; 41 ]; 42 ] 43 44 (* Every factor on one screen, one submit. A factor left unselected is simply 45 absent from the post, so a partial report needs no Skip. *) 46 let feedback_form request = 47 tag "form" 48 [ 49 action Routes.feedback; 50 post_form; 51 class_ "feedback-form"; 52 Dream_html.attr "data-hito-feedback-form"; 53 Dream_html.attr "data-hito-app-form"; 54 ] 55 [ 56 tag "fieldset" [] 57 ([ Dream_html.csrf_tag request ] 58 @ List.map 59 (fun (field, label) -> feedback_level_buttons field label) 60 feedback_factors 61 @ [ 62 tag "div" 63 [ class_ "button-group" ] 64 [ 65 tag "button" [ type_ "submit" ] [ txt "Submit feedback" ]; 66 tag "a" 67 [ 68 href Routes.logbook; 69 class_ "button secondary"; 70 Dream_html.attr "data-hito-feedback-dismiss"; 71 ] 72 [ txt "Cancel" ]; 73 ]; 74 ]); 75 ] 76 77 let feedback_modal request ~open_ = 78 tag "dialog" 79 ([ class_ "feedback-modal"; Dream_html.attr "data-hito-feedback-modal" ] 80 @ if open_ then [ Dream_html.attr "open" ] else []) 81 [ 82 tag "div" 83 [ class_ "feedback-modal-content" ] 84 [ tag "h2" [] [ txt "How are you feeling?" ]; feedback_form request ]; 85 ] 86 87 let delete_record_dialog request (record : View_model.Recorded_workout.t) = 88 tag "dialog" 89 [ class_ "confirm-dialog"; Dream_html.attr "open" ] 90 [ 91 tag "h2" [] [ txt "Delete this workout log?" ]; 92 tag "p" [] [ txt "Delete %s. This cannot be undone." record.workout_name ]; 93 tag "div" 94 [ class_ "button-group" ] 95 [ 96 tag "form" 97 [ 98 action Routes.delete_record record.id; 99 post_form; 100 class_ "delete-record-form"; 101 ] 102 [ 103 Dream_html.csrf_tag request; 104 tag "button" 105 [ type_ "submit"; class_ "danger" ] 106 [ txt "Delete workout" ]; 107 ]; 108 tag "a" 109 [ 110 href Routes.logbook; 111 class_ "button secondary"; 112 Dream_html.attr "data-hito-confirm-dismiss"; 113 ] 114 [ txt "Cancel" ]; 115 ]; 116 ] 117 118 (* An inline coloured delta for one metric: green for an increase, oxblood for a 119 decrease, following the app's progress palette. [None] when there is no prior 120 value or the value is unchanged, so no parenthetical is shown. *) 121 let duration_text ~seconds ~previous_seconds = 122 match (seconds, previous_seconds) with 123 | None, _ -> None 124 | Some seconds, None -> Some (Printf.sprintf "%d min (—)" (seconds / 60)) 125 | Some seconds, Some previous_seconds -> 126 let delta_seconds = seconds - previous_seconds in 127 let value = Printf.sprintf "%d min" (seconds / 60) in 128 let delta_minutes = float_of_int delta_seconds /. 60. in 129 Some (Printf.sprintf "%s (%+.1f min)" value delta_minutes) 130 131 let format_load load = Printf.sprintf "%g" load 132 let format_reps reps = string_of_int reps 133 134 let delta_value ~previous ~current = 135 let delta = current -. previous in 136 let class_name, value = 137 if Float.equal delta 0. then ("comparison-delta", "—") 138 else if delta > 0. then 139 ("comparison-delta delta-up", Printf.sprintf "%+g" delta) 140 else ("comparison-delta delta-down", Printf.sprintf "%+g" delta) 141 in 142 (class_name, value) 143 144 let value_cell class_name value = 145 tag "td" [ class_ "%s" class_name ] [ txt "%s" value ] 146 147 let delta_cell ~previous ~current = 148 let class_name, value = delta_value ~previous ~current in 149 tag "td" [ class_ "%s" class_name ] [ txt "%s" value ] 150 151 let comparison_rows (row : View_model.Exercise_comparison.t) = 152 let exercise_cell = 153 tag "th" 154 [ Dream_html.string_attr "scope" "row"; class_ "evidence-exercise" ] 155 [ txt "%s" row.exercise_name ] 156 in 157 [ 158 tag "tr" [] 159 [ 160 exercise_cell; 161 value_cell "observation-current" (format_load row.current_load); 162 delta_cell 163 ~previous:(Option.value ~default:row.current_load row.previous_load) 164 ~current:row.current_load; 165 value_cell "observation-current" (format_reps row.current_reps); 166 delta_cell 167 ~previous: 168 (float_of_int 169 (Option.value ~default:row.current_reps row.previous_reps)) 170 ~current:(float_of_int row.current_reps); 171 ]; 172 ] 173 174 let workout_evidence_table (entry : View_model.Recorded_workout.t) groups = 175 let caption = 176 [ 177 tag "strong" 178 [ class_ "workout-evidence-caption-name" ] 179 [ txt "%s" entry.workout_name ]; 180 tag "span" 181 [ class_ "workout-evidence-caption-status" ] 182 [ txt "Performed on %s" entry.performed_on ]; 183 ] 184 in 185 Page_kit.workout_evidence_table ~caption_class:"workout-evidence-caption" 186 ~caption 187 (List.map 188 (fun (group : View_model.Stimulus_group.t) -> 189 tag "tbody" [] (List.concat_map comparison_rows group.rows)) 190 groups) 191 192 let calendar_month_name month = 193 List.nth 194 [ 195 "January"; 196 "February"; 197 "March"; 198 "April"; 199 "May"; 200 "June"; 201 "July"; 202 "August"; 203 "September"; 204 "October"; 205 "November"; 206 "December"; 207 ] 208 (month - 1) 209 210 let workout_metadata (entry : View_model.Recorded_workout.t) = 211 let duration_item = 212 match 213 duration_text ~seconds:entry.duration_seconds 214 ~previous_seconds:entry.duration_previous_seconds 215 with 216 | None -> [] 217 | Some value -> 218 [ tag "dt" [] [ txt "Duration" ]; tag "dd" [] [ txt "%s" value ] ] 219 in 220 let next_item = 221 Option.to_list 222 (Option.map 223 (fun next -> 224 [ tag "dt" [] [ txt "Next planned" ]; tag "dd" [] [ txt "%s" next ] ]) 225 entry.next_scheduled) 226 |> List.concat 227 in 228 tag "dl" 229 [ class_ "workout-metadata" ] 230 (duration_item 231 @ [ 232 tag "dt" [] [ txt "Performed" ]; 233 tag "dd" [] [ txt "%s" entry.performed_ago ]; 234 ] 235 @ next_item) 236 237 let selected_workout_card (entry : View_model.Recorded_workout.t) = 238 let performance = 239 if entry.groups = [] then [ tag "p" [] [ txt "No exercises recorded" ] ] 240 else [ workout_evidence_table entry entry.groups ] 241 in 242 let actions = 243 if entry.imported then [] 244 else 245 [ 246 tag "div" 247 [ class_ "logbook-entry-actions" ] 248 [ 249 tag "a" 250 [ 251 href Routes.record entry.id; 252 class_ "button secondary"; 253 Dream_html.attr "data-hito-app-link"; 254 ] 255 [ txt "Edit" ]; 256 tag "a" 257 [ 258 Dream_html.string_attr "href" "/logbook?delete=%s" entry.id; 259 class_ "button outline danger"; 260 ] 261 [ txt "Delete" ]; 262 ]; 263 ] 264 in 265 let imported = 266 if entry.imported then 267 [ tag "span" [ class_ "logbook-import-pill" ] [ txt "Imported" ] ] 268 else [] 269 in 270 tag "article" 271 [ class_ "selected-workout" ] 272 (imported @ performance @ [ workout_metadata entry ] @ actions) 273 274 let selected_upcoming_card = upcoming_workout_card 275 276 let selected_date_panel (selected : View_model.Selected_date.t) = 277 let completed = List.map selected_workout_card selected.completed in 278 let planned = 279 Option.to_list (Option.map selected_upcoming_card selected.upcoming) 280 in 281 let content = 282 if completed = [] && planned = [] then 283 [ tag "p" [] [ txt "No workout was recorded or planned on this day." ] ] 284 else completed @ planned 285 in 286 tag "section" [ class_ "selected-day" ] content 287 288 (* The month calendar above the selected-day panel. Days that carry a workout 289 are highlighted; today is marked. A header row holds a previous arrow, the 290 month and year, a Today link, and a next arrow. The arrows page the query. *) 291 let calendar_grid (cal : View_model.Calendar.t) = 292 let cell = function 293 | View_model.Calendar.Blank -> tag "td" [ class_ "calendar-blank" ] [] 294 | View_model.Calendar.Day d -> 295 let classes = 296 "calendar-day" 297 ^ (if d.workout then " calendar-workout" else "") 298 ^ (if Option.is_some d.scheduled_workout then " calendar-planned" 299 else "") 300 ^ (if d.selected then " calendar-selected" else "") 301 ^ if d.today then " calendar-today" else "" 302 in 303 let aria_label = 304 match (d.workout, d.scheduled_workout, d.today) with 305 | true, Some name, true -> 306 Some 307 (Printf.sprintf "%d: completed, planned %s, today" 308 d.day_of_month name) 309 | true, Some name, false -> 310 Some 311 (Printf.sprintf "%d: completed, planned %s" d.day_of_month name) 312 | true, None, true -> 313 Some 314 (Printf.sprintf "%d: completed workout, today" d.day_of_month) 315 | true, None, false -> 316 Some (Printf.sprintf "%d: completed workout" d.day_of_month) 317 | false, Some name, true -> 318 Some (Printf.sprintf "%d: planned %s, today" d.day_of_month name) 319 | false, Some name, false -> 320 Some (Printf.sprintf "%d: planned %s" d.day_of_month name) 321 | false, None, true -> 322 Some (Printf.sprintf "%d: today" d.day_of_month) 323 | false, None, false -> None 324 in 325 let title = 326 Option.map 327 (fun name -> Printf.sprintf "Planned: %s" name) 328 d.scheduled_workout 329 in 330 let href = 331 Printf.sprintf "/logbook?year=%d&month=%d&day=%d" cal.selected_year 332 cal.selected_month d.day_of_month 333 in 334 tag "td" 335 ([ Dream_html.string_attr "class" "%s" classes ] 336 @ Option.to_list 337 (Option.map (Dream_html.string_attr "aria-label" "%s") aria_label) 338 @ Option.to_list 339 (Option.map (Dream_html.string_attr "title" "%s") title)) 340 [ 341 tag "a" 342 [ 343 Dream_html.string_attr "href" "%s" href; 344 class_ "calendar-day-link"; 345 Dream_html.attr "data-hito-app-link"; 346 Dream_html.string_attr "aria-label" "Select day %d" 347 d.day_of_month; 348 ] 349 [ txt "%d" d.day_of_month ]; 350 ] 351 in 352 let prev_month = 353 if cal.selected_month = 1 then (cal.selected_year - 1, 12) 354 else (cal.selected_year, cal.selected_month - 1) 355 in 356 let next_month = 357 if cal.selected_month = 12 then (cal.selected_year + 1, 1) 358 else (cal.selected_year, cal.selected_month + 1) 359 in 360 let arrow ~label (y, m) rel = 361 tag "a" 362 [ 363 Dream_html.string_attr "href" "/logbook?year=%d&month=%d" y m; 364 class_ "calendar-arrow"; 365 Dream_html.attr "data-hito-app-link"; 366 Dream_html.string_attr "aria-label" "%s" rel; 367 ] 368 [ txt "%s" label ] 369 in 370 (* Header: previous, the month and year, a Today link that returns to the 371 current month, next. *) 372 tag "section" 373 [ 374 class_ "logbook-calendar"; 375 Dream_html.string_attr "aria-labelledby" "calendar-heading"; 376 ] 377 [ 378 tag "h2" [ id "calendar-heading"; class_ "sr-only" ] [ txt "Calendar" ]; 379 tag "div" 380 [ class_ "calendar" ] 381 [ 382 tag "nav" 383 [ 384 class_ "calendar-header"; 385 Dream_html.string_attr "aria-label" "Calendar navigation"; 386 ] 387 [ 388 arrow ~label:"‹" prev_month "Previous month"; 389 tag "span" 390 [ class_ "calendar-month" ] 391 [ 392 txt "%s %d" 393 (calendar_month_name cal.selected_month) 394 cal.selected_year; 395 ]; 396 tag "a" 397 [ 398 href Routes.logbook; 399 class_ "calendar-today-link"; 400 Dream_html.attr "data-hito-app-link"; 401 ] 402 [ txt "Today" ]; 403 arrow ~label:"›" next_month "Next month"; 404 ]; 405 tag "table" 406 [ class_ "calendar-grid" ] 407 [ 408 tag "thead" [] 409 [ 410 tag "tr" [] 411 (List.map 412 (fun h -> 413 tag "th" 414 [ Dream_html.string_attr "scope" "col" ] 415 [ txt "%s" h ]) 416 cal.weekday_headings); 417 ]; 418 tag "tbody" [] 419 (List.map 420 (fun week -> tag "tr" [] (List.map cell week)) 421 cal.weeks); 422 ]; 423 tag "div" 424 [ 425 class_ "calendar-legend"; 426 Dream_html.string_attr "role" "group"; 427 Dream_html.string_attr "aria-label" "Calendar key"; 428 ] 429 [ 430 tag "span" 431 [ class_ "calendar-legend-item" ] 432 [ 433 tag "span" 434 [ class_ "calendar-legend-swatch calendar-legend-workout" ] 435 []; 436 txt "Completed"; 437 ]; 438 tag "span" 439 [ class_ "calendar-legend-item" ] 440 [ 441 tag "span" 442 [ class_ "calendar-legend-swatch calendar-legend-planned" ] 443 []; 444 txt "Planned"; 445 ]; 446 tag "span" 447 [ class_ "calendar-legend-item" ] 448 [ 449 tag "span" 450 [ class_ "calendar-legend-swatch calendar-legend-today" ] 451 []; 452 txt "Today"; 453 ]; 454 ]; 455 tag "p" 456 [ class_ "calendar-note" ] 457 [ 458 txt 459 "Planned dates follow the recovery time in your routine. Hover \ 460 a planned day to see the workout."; 461 ]; 462 ]; 463 ] 464 465 let logbook_tabs ~active_tab = 466 link_tabs ~aria_label:"Logbook views" ~active:active_tab 467 [ 468 ("calendar", "Calendar", "/logbook?tab=calendar"); 469 ("progress", "Progress", "/logbook?tab=progress"); 470 ] 471 472 let progress_legend () = 473 tag "div" 474 [ class_ "chart-legend" ] 475 [ 476 tag "span" 477 [ class_ "chart-legend-item weight" ] 478 [ txt "Weight (kg), left axis" ]; 479 tag "span" [ class_ "chart-legend-item reps" ] [ txt "Reps, right axis" ]; 480 ] 481 482 let progress_chart_section (chart : View_model.Progress_page.chart) = 483 tag "section" 484 [ class_ "progress-chart-section" ] 485 [ 486 tag "h2" [] [ txt "%s" chart.exercise_name ]; 487 progress_legend (); 488 Svg_chart.render chart; 489 ] 490 491 let progress_content charts = 492 match charts with 493 | [] -> 494 [ 495 tag "p" [] 496 [ 497 txt 498 "No exercise has enough history to chart yet. Log or import at \ 499 least two performances of an exercise to see its progress."; 500 ]; 501 ] 502 | charts -> List.map progress_chart_section charts 503 504 let logbook request ?workout_started ~viewer ?calendar ?selected_date 505 ?(suggest_feedback = false) ?(feedback_open = false) ?delete_record 506 ?(tab = "calendar") ?progress_charts () = 507 let active_tab = 508 if String.equal tab "progress" then "progress" else "calendar" 509 in 510 let content = 511 match active_tab with 512 | "calendar" -> 513 (match calendar with Some cal -> [ calendar_grid cal ] | None -> []) 514 @ [ 515 (match selected_date with 516 | Some selected -> selected_date_panel selected 517 | None -> 518 tag "section" 519 [ class_ "selected-day empty-selected-day" ] 520 [ tag "p" [] [ txt "Select a day to view its workout." ] ]); 521 ] 522 | "progress" -> Option.value progress_charts ~default:[] |> progress_content 523 | _ -> [] 524 in 525 html_page ~viewer ~request ~active:"logbook" ?workout_started "Logbook" 526 ([ 527 logbook_tabs ~active_tab; 528 (if suggest_feedback then 529 tag "p" 530 [ class_ "eyebrow" ] 531 [ txt "Workout saved — add feedback while it is fresh." ] 532 else txt ""); 533 ] 534 @ content 535 @ [ feedback_modal request ~open_:feedback_open ] 536 @ 537 match delete_record with 538 | Some record -> [ delete_record_dialog request record ] 539 | None -> []) 540