refactor Remove relocated presenter sources

Complete the presenter extraction by removing the former web-library source files.

Commit
bacd3c8c9d994e8ea8f15710baf1298ca67957f6
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/web/pages.ml
index ed1cc4a0..00000000 100644..000000
@@ -1,2057 +0,0 @@
1 Removed: open Dream_html
2 Removed:
3 Removed: type page = Dream_html.node
4 Removed:
5 Removed: let tag = Dream_html.std_tag
6 Removed: let void = Dream_html.void_tag
7 Removed: let class_ = Dream_html.string_attr "class"
8 Removed: let name = Dream_html.string_attr "name"
9 Removed: let value = Dream_html.string_attr "value"
10 Removed: let rel = Dream_html.string_attr "rel"
11 Removed: let type_ = Dream_html.string_attr "type"
12 Removed: let step = Dream_html.string_attr "step"
13 Removed: let id = Dream_html.string_attr "id"
14 Removed: let tabindex = Dream_html.string_attr "tabindex"
15 Removed: let required = Dream_html.attr "required"
16 Removed: let href path = Dream_html.path_attr (Dream_html.uri_attr "href") path
17 Removed: let src path = Dream_html.path_attr (Dream_html.uri_attr "src") path
18 Removed: let action path = Dream_html.path_attr (Dream_html.uri_attr "action") path
19 Removed: let post_form = Dream_html.string_attr "method" "post"
20 Removed: let flash_key = "hito.flash"
21 Removed:
22 Removed: (* The shell. [viewer] and [request] are present on authenticated pages, which
23 Removed: then show a logout control and the username on the history link. Auth pages
24 Removed: omit both. *)
25 Removed: let theme_session_key = "hito.theme"
26 Removed:
27 Removed: let html_page ?viewer ?request ?(active = "") ?(logging = false)
28 Removed: ?(show_nav = false) title content =
29 Removed: let spa_client = true in
30 Removed: let theme =
31 Removed: match request with
32 Removed: | Some request -> (
33 Removed: match Dream.session_field request theme_session_key with
34 Removed: | Some "dark" -> "dark"
35 Removed: | _ -> "light")
36 Removed: | None -> "light"
37 Removed: in
38 Removed: let nav_link page path label =
39 Removed: let attrs = [ href path ] in
40 Removed: let attrs =
41 Removed: if spa_client then Dream_html.attr "data-hito-app-link" :: attrs
42 Removed: else attrs
43 Removed: in
44 Removed: let attrs =
45 Removed: if String.equal page active then
46 Removed: Dream_html.string_attr "aria-current" "page" :: attrs
47 Removed: else attrs
48 Removed: in
49 Removed: tag "a" attrs [ txt "%s" label ]
50 Removed: in
51 Removed: (* Three always-available actions. The middle slot points to the routine
52 Removed: normally and to the workout in progress while logging, so a running
53 Removed: workout stays one tap away without adding a fourth destination. The logbook
54 Removed: tab always reads "Logbook". The username lives on its own profile control. *)
55 Removed: let navigation_links () =
56 Removed: [
57 Removed: nav_link "home" Routes.home "Home";
58 Removed: (if logging then nav_link "workout" Routes.workout "Current workout"
59 Removed: else nav_link "routine" Routes.routine "Routine");
60 Removed: nav_link "logbook" Routes.logbook "Logbook";
61 Removed: ]
62 Removed: in
63 Removed: (* The profile control names the signed-in trainee and opens a drop-down of
64 Removed: account actions: Profile, Settings, and Sign out. It sits in the masthead,
65 Removed: and on mobile it is the top-bar profile control the bottom nav has no room
66 Removed: for. Settings points at the profile page too — account settings live there
67 Removed: and there is no separate settings page. A <details> menu needs no client
68 Removed: script, so it works before the SPA enhancement loads. The Sign-out form is
69 Removed: nested inside and keeps its CSRF token. *)
70 Removed: let profile_area =
71 Removed: match (viewer, request) with
72 Removed: | Some (viewer : View_model.Viewer.t), Some request ->
73 Removed: let menu_link page path label =
74 Removed: let attrs = [ class_ "profile-menu-item"; href path ] in
75 Removed: let attrs = Dream_html.string_attr "role" "menuitem" :: attrs in
76 Removed: let attrs =
77 Removed: if spa_client then Dream_html.attr "data-hito-app-link" :: attrs
78 Removed: else attrs
79 Removed: in
80 Removed: let attrs =
81 Removed: if String.equal page active then
82 Removed: Dream_html.string_attr "aria-current" "page" :: attrs
83 Removed: else attrs
84 Removed: in
85 Removed: tag "a" attrs [ txt "%s" label ]
86 Removed: in
87 Removed: let summary_attrs =
88 Removed: if String.equal "profile" active || String.equal "settings" active
89 Removed: then
90 Removed: [ class_ "profile"; Dream_html.string_attr "aria-current" "page" ]
91 Removed: else [ class_ "profile" ]
92 Removed: in
93 Removed: [
94 Removed: tag "details"
95 Removed: [ class_ "profile-menu" ]
96 Removed: [
97 Removed: tag "summary" summary_attrs
98 Removed: [ txt "%s" viewer.View_model.Viewer.username ];
99 Removed: tag "div"
100 Removed: [
101 Removed: class_ "profile-menu-drawer";
102 Removed: Dream_html.string_attr "role" "menu";
103 Removed: Dream_html.string_attr "aria-label" "Profile menu";
104 Removed: ]
105 Removed: [
106 Removed: menu_link "profile" Routes.profile "Profile";
107 Removed: menu_link "settings" Routes.settings "Settings";
108 Removed: menu_link "app-feedback" Routes.app_feedback "Feedback";
109 Removed: tag "form"
110 Removed: [
111 Removed: action Routes.logout;
112 Removed: post_form;
113 Removed: class_ "logout";
114 Removed: Dream_html.attr "data-hito-app-form";
115 Removed: ]
116 Removed: [
117 Removed: Dream_html.csrf_tag request;
118 Removed: void "input"
119 Removed: [
120 Removed: type_ "submit";
121 Removed: class_ "profile-menu-item";
122 Removed: Dream_html.string_attr "role" "menuitem";
123 Removed: value "Sign out";
124 Removed: ];
125 Removed: ];
126 Removed: ];
127 Removed: ];
128 Removed: ]
129 Removed: | _ -> []
130 Removed: in
131 Removed: let account_area = [] in
132 Removed: let primary_nav =
133 Removed: match (viewer, request) with
134 Removed: | Some (_ : View_model.Viewer.t), Some _ ->
135 Removed: [ tag "nav" [ class_ "primary-nav" ] (navigation_links ()) ]
136 Removed: | _ when show_nav ->
137 Removed: [ tag "nav" [ class_ "primary-nav" ] (navigation_links ()) ]
138 Removed: | _ -> []
139 Removed: in
140 Removed: let bottom_nav =
141 Removed: match (viewer, request) with
142 Removed: | Some (_ : View_model.Viewer.t), Some _ ->
143 Removed: [
144 Removed: tag "nav"
145 Removed: [
146 Removed: class_ "bottom-nav";
147 Removed: Dream_html.string_attr "aria-label" "Mobile navigation";
148 Removed: ]
149 Removed: (navigation_links ());
150 Removed: ]
151 Removed: | _ when show_nav ->
152 Removed: [
153 Removed: tag "nav"
154 Removed: [
155 Removed: class_ "bottom-nav";
156 Removed: Dream_html.string_attr "aria-label" "Mobile navigation";
157 Removed: ]
158 Removed: (navigation_links ());
159 Removed: ]
160 Removed: | _ -> []
161 Removed: in
162 Removed: let flash_notice =
163 Removed: match request with
164 Removed: | Some request -> (
165 Removed: match Dream.session_field request flash_key with
166 Removed: | Some message ->
167 Removed: [
168 Removed: tag "div"
169 Removed: [
170 Removed: class_ "toast";
171 Removed: Dream_html.attr "data-hito-toast";
172 Removed: Dream_html.string_attr "role" "status";
173 Removed: Dream_html.string_attr "aria-live" "polite";
174 Removed: ]
175 Removed: [ txt "%s" message ];
176 Removed: ]
177 Removed: | None -> [])
178 Removed: | None -> []
179 Removed: in
180 Removed: tag "html"
181 Removed: [
182 Removed: Dream_html.string_attr "lang" "en";
183 Removed: Dream_html.string_attr "data-theme" "%s" theme;
184 Removed: ]
185 Removed: [
186 Removed: tag "head" []
187 Removed: [
188 Removed: tag "title" [] [ txt "Hito — %s" title ];
189 Removed: void "meta" [ Dream_html.string_attr "charset" "utf-8" ];
190 Removed: void "meta"
191 Removed: [
192 Removed: Dream_html.string_attr "name" "viewport";
193 Removed: Dream_html.string_attr "content"
194 Removed: "width=device-width,initial-scale=1";
195 Removed: ];
196 Removed: void "link" [ rel "stylesheet"; href Routes.stylesheet ];
197 Removed: ];
198 Removed: tag "body"
199 Removed: [ Dream_html.string_attr "class" "hito-app" ]
200 Removed: (flash_notice
201 Removed: @ [
202 Removed: tag "a"
203 Removed: [
204 Removed: class_ "skip-link";
205 Removed: Dream_html.string_attr "href" "#main-content";
206 Removed: ]
207 Removed: [ txt "Skip to main content" ];
208 Removed: tag "div"
209 Removed: ([
210 Removed: Dream_html.string_attr "class" "app-shell page-%s" active;
211 Removed: Dream_html.string_attr "data-hito-page-title" "Hito — %s" title;
212 Removed: ]
213 Removed: @
214 Removed: if spa_client then [ Dream_html.attr "data-hito-app-shell" ]
215 Removed: else [])
216 Removed: ([
217 Removed: tag "header"
218 Removed: [ class_ "masthead" ]
219 Removed: ([
220 Removed: tag "a"
221 Removed: ([ class_ "brand"; href Routes.home ]
222 Removed: @
223 Removed: if spa_client then
224 Removed: [ Dream_html.attr "data-hito-app-link" ]
225 Removed: else [])
226 Removed: [ txt "hito" ];
227 Removed: ]
228 Removed: @ primary_nav @ profile_area @ account_area);
229 Removed: tag "main"
230 Removed: [ id "main-content"; tabindex "-1" ]
231 Removed: [
232 Removed: tag "div"
233 Removed: ([ class_ "page-surface" ]
234 Removed: @
235 Removed: if spa_client then
236 Removed: [ Dream_html.attr "data-hito-app-content" ]
237 Removed: else [])
238 Removed: content;
239 Removed: ];
240 Removed: ]
241 Removed: @ bottom_nav);
242 Removed: ]
243 Removed: @
244 Removed: if spa_client then
245 Removed: [
246 Removed: tag "script"
247 Removed: [ src Routes.workout_client; Dream_html.attr "defer" ]
248 Removed: [];
249 Removed: ]
250 Removed: else []);
251 Removed: ]
252 Removed:
253 Removed: let problem ~title ~detail =
254 Removed: html_page title
255 Removed: [
256 Removed: tag "p" [ class_ "eyebrow" ] [ txt "Attention required" ];
257 Removed: tag "h1" [] [ txt "%s" title ];
258 Removed: tag "p" [ class_ "warn" ] [ txt "%s" detail ];
259 Removed: ]
260 Removed:
261 Removed: (* A branded error page for the common HTTP failures. It states only the class
262 Removed: of failure, never a server-supplied string, so nothing internal leaks. The
263 Removed: navigation stays present — top on desktop, bottom on mobile — so a lost
264 Removed: visitor is one tap from a known destination. *)
265 Removed: let error_page ?viewer ?request ~status () =
266 Removed: let title, detail =
267 Removed: match status with
268 Removed: | 404 ->
269 Removed: ("Page not found", "That page does not exist. Use the navigation below.")
270 Removed: | 400 -> ("Bad request", "That request could not be understood.")
271 Removed: | 403 -> ("Forbidden", "You do not have access to that page.")
272 Removed: | s when s >= 500 ->
273 Removed: ("Something went wrong", "The server hit a problem. Try again shortly.")
274 Removed: | _ -> ("Something went wrong", "That request could not be completed.")
275 Removed: in
276 Removed: html_page ?viewer ?request ~show_nav:true title
277 Removed: [
278 Removed: tag "p" [ class_ "eyebrow" ] [ txt "%d" status ];
279 Removed: tag "h1" [] [ txt "%s" title ];
280 Removed: tag "p" [ class_ "warn" ] [ txt "%s" detail ];
281 Removed: ]
282 Removed:
283 Removed: (* --- authentication --- *)
284 Removed:
285 Removed: let auth_error = function
286 Removed: | None -> []
287 Removed: | Some message ->
288 Removed: [
289 Removed: tag "p"
290 Removed: [ class_ "warn"; Dream_html.string_attr "role" "alert" ]
291 Removed: [ txt "%s" message ];
292 Removed: ]
293 Removed:
294 Removed: let credentials_form request ~submit ~action_path =
295 Removed: tag "form"
296 Removed: [ action action_path; post_form; Dream_html.attr "data-hito-app-form" ]
297 Removed: [
298 Removed: Dream_html.csrf_tag request;
299 Removed: tag "div"
300 Removed: [ class_ "field" ]
301 Removed: [
302 Removed: tag "label"
303 Removed: [ Dream_html.string_attr "for" "username" ]
304 Removed: [ txt "Username" ];
305 Removed: void "input"
306 Removed: [
307 Removed: type_ "text";
308 Removed: name "username";
309 Removed: Dream_html.string_attr "id" "username";
310 Removed: Dream_html.string_attr "autocomplete" "username";
311 Removed: required;
312 Removed: ];
313 Removed: ];
314 Removed: tag "div"
315 Removed: [ class_ "field" ]
316 Removed: [
317 Removed: tag "label"
318 Removed: [ Dream_html.string_attr "for" "password" ]
319 Removed: [ txt "Password" ];
320 Removed: void "input"
321 Removed: [
322 Removed: type_ "password";
323 Removed: name "password";
324 Removed: Dream_html.string_attr "id" "password";
325 Removed: Dream_html.string_attr "autocomplete" "current-password";
326 Removed: required;
327 Removed: ];
328 Removed: ];
329 Removed: void "input" [ type_ "submit"; value submit ];
330 Removed: ]
331 Removed:
332 Removed: let login request ?error ?(registration_open = false) () =
333 Removed: let register_prompt =
334 Removed: if registration_open then
335 Removed: [
336 Removed: tag "p" []
337 Removed: [
338 Removed: txt "No account yet? ";
339 Removed: tag "a"
340 Removed: [ href Routes.register; Dream_html.attr "data-hito-app-link" ]
341 Removed: [ txt "Register" ];
342 Removed: ];
343 Removed: ]
344 Removed: else []
345 Removed: in
346 Removed: html_page ~active:"auth" "Sign in"
347 Removed: ([
348 Removed: tag "h1" [] [ txt "Sign in" ];
349 Removed: credentials_form request ~submit:"Sign in" ~action_path:Routes.login;
350 Removed: ]
351 Removed: @ register_prompt @ auth_error error)
352 Removed:
353 Removed: let register request ?error () =
354 Removed: html_page ~active:"auth" "Register"
355 Removed: ([
356 Removed: tag "h1" [] [ txt "Create an account" ];
357 Removed: credentials_form request ~submit:"Register" ~action_path:Routes.register;
358 Removed: tag "p" []
359 Removed: [
360 Removed: txt "Already registered? ";
361 Removed: tag "a"
362 Removed: [ href Routes.login; Dream_html.attr "data-hito-app-link" ]
363 Removed: [ txt "Sign in" ];
364 Removed: ];
365 Removed: ]
366 Removed: @ auth_error error)
367 Removed:
368 Removed: (* --- application pages --- *)
369 Removed:
370 Removed: let error_id input_id = input_id ^ "-error"
371 Removed:
372 Removed: let extension_select ?(selected = "") ?(invalid = false) ~input_id () =
373 Removed: let error_attrs =
374 Removed: if invalid then
375 Removed: [
376 Removed: Dream_html.string_attr "aria-invalid" "true";
377 Removed: Dream_html.string_attr "aria-describedby" "%s" (error_id input_id);
378 Removed: ]
379 Removed: else []
380 Removed: in
381 Removed: let option (code : string) label =
382 Removed: let attrs = [ Dream_html.string_attr "value" "%s" code ] in
383 Removed: let attrs =
384 Removed: if String.equal code selected then Dream_html.attr "selected" :: attrs
385 Removed: else attrs
386 Removed: in
387 Removed: tag "option" attrs [ txt "%s" label ]
388 Removed: in
389 Removed: tag "select"
390 Removed: ([ name "extension"; Dream_html.string_attr "id" "%s" input_id ]
391 Removed: @ error_attrs)
392 Removed: [
393 Removed: option "" "to positive failure";
394 Removed: option "forced" "then forced reps";
395 Removed: option "negatives" "then negatives";
396 Removed: option "rest-pause" "then rest-pause";
397 Removed: option "static" "then a static hold";
398 Removed: ]
399 Removed:
400 Removed: let choose_routine request ~logging ~viewer
401 Removed: ~(routines : View_model.Routine_choice.t list) =
402 Removed: html_page ~viewer ~request ~active:"home" ~logging "Choose a routine"
403 Removed: [
404 Removed: tag "h1" [] [ txt "Routines" ];
405 Removed: tag "div" []
406 Removed: (List.map
407 Removed: (fun (choice : View_model.Routine_choice.t) ->
408 Removed: tag "form"
409 Removed: [
410 Removed: action Routes.select_routine choice.id;
411 Removed: post_form;
412 Removed: Dream_html.attr "data-hito-app-form";
413 Removed: ]
414 Removed: [
415 Removed: Dream_html.csrf_tag request;
416 Removed: tag "fieldset" []
417 Removed: [
418 Removed: tag "legend" [] [ txt "%s" choice.name ];
419 Removed: tag "p" []
420 Removed: [ txt "%d workouts in the cycle." choice.workout_count ];
421 Removed: void "input" [ type_ "submit"; value "Use this routine" ];
422 Removed: ];
423 Removed: ])
424 Removed: routines);
425 Removed: ]
426 Removed:
427 Removed: let begin_form request ~routine_id ~override label =
428 Removed: tag "form"
429 Removed: [ action Routes.workout; post_form; Dream_html.attr "data-hito-app-form" ]
430 Removed: [
431 Removed: Dream_html.csrf_tag request;
432 Removed: void "input"
433 Removed: [
434 Removed: type_ "hidden";
435 Removed: name "routine";
436 Removed: Dream_html.string_attr "value" "%s" routine_id;
437 Removed: ];
438 Removed: void "input"
439 Removed: [
440 Removed: type_ "hidden";
441 Removed: name "override";
442 Removed: Dream_html.string_attr "value" "%s" (string_of_bool override);
443 Removed: ];
444 Removed: void "input" [ type_ "submit"; value label ];
445 Removed: ]
446 Removed:
447 Removed: (* Beginning under override needs an explicit acknowledgement, so its submit is
448 Removed: gated behind a confirmation modal. Without script the form submits at once —
449 Removed: the doctrine's deviation stays possible. The modal only makes it deliberate.
450 Removed: The client reuses the shared confirm machinery: [data-hito-confirm-cancel]
451 Removed: opens the dialog, [data-hito-confirm-form] is the form its accept submits. *)
452 Removed: let override_begin request ~routine_id =
453 Removed: [
454 Removed: tag "form"
455 Removed: [
456 Removed: action Routes.workout;
457 Removed: post_form;
458 Removed: Dream_html.attr "data-hito-app-form";
459 Removed: Dream_html.attr "data-hito-confirm-form";
460 Removed: ]
461 Removed: [
462 Removed: Dream_html.csrf_tag request;
463 Removed: void "input"
464 Removed: [
465 Removed: type_ "hidden";
466 Removed: name "routine";
467 Removed: Dream_html.string_attr "value" "%s" routine_id;
468 Removed: ];
469 Removed: void "input"
470 Removed: [
471 Removed: type_ "hidden";
472 Removed: name "override";
473 Removed: Dream_html.string_attr "value" "true";
474 Removed: ];
475 Removed: void "input"
476 Removed: [
477 Removed: type_ "submit";
478 Removed: value "Begin under override";
479 Removed: Dream_html.attr "data-hito-confirm-cancel";
480 Removed: ];
481 Removed: ];
482 Removed: tag "dialog"
483 Removed: [ class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal" ]
484 Removed: [
485 Removed: tag "h2" [] [ txt "Begin before recovery is complete?" ];
486 Removed: tag "p" []
487 Removed: [
488 Removed: txt
489 Removed: "Heavy Duty grows muscle during recovery, not in the gym. \
490 Removed: Training early can cut into it. This is recorded as an \
491 Removed: override.";
492 Removed: ];
493 Removed: tag "div"
494 Removed: [ class_ "button-group" ]
495 Removed: [
496 Removed: tag "button"
497 Removed: [
498 Removed: type_ "button";
499 Removed: class_ "secondary";
500 Removed: Dream_html.attr "data-hito-confirm-dismiss";
501 Removed: ]
502 Removed: [ txt "Wait to recover" ];
503 Removed: tag "button"
504 Removed: [ type_ "button"; Dream_html.attr "data-hito-confirm-accept" ]
505 Removed: [ txt "Begin under override" ];
506 Removed: ];
507 Removed: ];
508 Removed: ]
509 Removed:
510 Removed: let home request ~viewer ~home:(vm : View_model.Home.t) =
511 Removed: let status, gate =
512 Removed: match vm.gate with
513 Removed: | View_model.Home.Ready ->
514 Removed: ( "Recovery is complete.",
515 Removed: [
516 Removed: begin_form request ~routine_id:vm.routine_id ~override:false
517 Removed: "Begin next workout";
518 Removed: ] )
519 Removed: | View_model.Home.Recovering { status } ->
520 Removed: ( status,
521 Removed: [
522 Removed: tag "div"
523 Removed: [ class_ "warn" ]
524 Removed: ([
525 Removed: tag "p" []
526 Removed: [
527 Removed: txt "Heavy Duty requires recovery before the next workout.";
528 Removed: ];
529 Removed: ]
530 Removed: @ override_begin request ~routine_id:vm.routine_id);
531 Removed: ] )
532 Removed: in
533 Removed: html_page ~viewer ~request ~active:"home" "Home"
534 Removed: ([
535 Removed: tag "h1" [] [ txt "%s" vm.routine_name ];
536 Removed: tag "p" [ class_ "ledger-meta" ] [ txt "Next: %s" vm.next_workout ];
537 Removed: tag "p" [ class_ "ledger-meta" ] [ txt "%s" status ];
538 Removed: ]
539 Removed: @ gate)
540 Removed:
541 Removed: (* Home while a workout is in progress. Rather than the recovery gate or the
542 Removed: routine chooser, Home shows a single card: the workout is under way and one
543 Removed: tap returns to it. The middle navigation action already reads "Current
544 Removed: workout" here, and this card is the matching Home affordance. *)
545 Removed: let workout_in_progress request ~viewer ~workout_name =
546 Removed: html_page ~viewer ~request ~active:"home" ~logging:true "Home"
547 Removed: [
548 Removed: tag "a"
549 Removed: [
550 Removed: class_ "in-progress-card";
551 Removed: href Routes.workout;
552 Removed: Dream_html.attr "data-hito-app-link";
553 Removed: ]
554 Removed: [
555 Removed: tag "p" [ class_ "eyebrow" ] [ txt "Workout in progress" ];
556 Removed: tag "h1" [] [ txt "%s" workout_name ];
557 Removed: tag "p" [] [ txt "Return to the current workout to keep logging." ];
558 Removed: ];
559 Removed: ]
560 Removed:
561 Removed: let routine request ?(logging = false) ~viewer (vm : View_model.Routine.t) =
562 Removed: html_page ~viewer ~request ~active:"routine" ~logging "Routine"
563 Removed: [
564 Removed: tag "h1" [] [ txt "%s" vm.name ];
565 Removed: tag "div"
566 Removed: [ class_ "routine-accordions" ]
567 Removed: (List.map
568 Removed: (fun (workout : View_model.Routine.workout) ->
569 Removed: tag "details"
570 Removed: [ class_ "routine-accordion" ]
571 Removed: [
572 Removed: tag "summary" [] [ txt "%s" workout.name ];
573 Removed: tag "div"
574 Removed: [ class_ "routine-description" ]
575 Removed: [
576 Removed: tag "ul" []
577 Removed: (List.map
578 Removed: (fun (exercise : View_model.Routine.exercise) ->
579 Removed: tag "li" []
580 Removed: [
581 Removed: tag "span"
582 Removed: [ class_ "routine-exercise" ]
583 Removed: [ txt "%s" exercise.name ];
584 Removed: tag "span"
585 Removed: [ class_ "routine-reps" ]
586 Removed: [ txt "%s" exercise.reps ];
587 Removed: ])
588 Removed: workout.exercises);
589 Removed: ];
590 Removed: ])
591 Removed: vm.workouts);
592 Removed: ]
593 Removed:
594 Removed: let error_for ~input_id (field : string) (errors : (string * string) list) =
595 Removed: match List.assoc_opt field errors with
596 Removed: | None -> []
597 Removed: | Some error ->
598 Removed: [
599 Removed: tag "p"
600 Removed: [
601 Removed: Dream_html.string_attr "id" "%s" (error_id input_id);
602 Removed: class_ "warn";
603 Removed: Dream_html.string_attr "role" "alert";
604 Removed: ]
605 Removed: [ txt "%s" (Decode.errors_to_text [ (field, error) ]) ];
606 Removed: ]
607 Removed:
608 Removed: let input_row ?value:v ?(invalid = false) ?placeholder ?(hide_label = false)
609 Removed: ~input_id (field : string) label =
610 Removed: let error_attrs =
611 Removed: if invalid then
612 Removed: [
613 Removed: Dream_html.string_attr "aria-invalid" "true";
614 Removed: Dream_html.string_attr "aria-describedby" "%s" (error_id input_id);
615 Removed: ]
616 Removed: else []
617 Removed: in
618 Removed: let placeholder_attr =
619 Removed: match placeholder with
620 Removed: | None -> []
621 Removed: | Some p -> [ Dream_html.string_attr "placeholder" "%s" p ]
622 Removed: in
623 Removed: tag "div"
624 Removed: [ class_ "field" ]
625 Removed: [
626 Removed: tag "label"
627 Removed: [
628 Removed: Dream_html.string_attr "for" "%s" input_id;
629 Removed: class_ (if hide_label then "sr-only" else "");
630 Removed: ]
631 Removed: [ txt "%s" label ];
632 Removed: void "input"
633 Removed: ([
634 Removed: type_ "number";
635 Removed: Dream_html.string_attr "name" "%s" field;
636 Removed: Dream_html.string_attr "id" "%s" input_id;
637 Removed: step "0.5";
638 Removed: required;
639 Removed: ]
640 Removed: @ placeholder_attr @ error_attrs
641 Removed: @
642 Removed: match v with
643 Removed: | None -> []
644 Removed: | Some v -> [ Dream_html.string_attr "value" "%s" v ]);
645 Removed: ]
646 Removed:
647 Removed: (* A movement's fields as cells of the fieldset grid: the exercise name, the
648 Removed: load input, and the reps input. The cells are direct participants of the one
649 Removed: [.logging-grid] the fieldset wraps them in — a [display: contents] wrapper
650 Removed: groups a movement without opening a nested grid — so every movement's name,
651 Removed: load, and reps columns line up across the whole fieldset. The name reads as a
652 Removed: heading, the inputs as labelled fields. *)
653 Removed: let movement_row ~(errors : (string * string) list) ~name ~load_id ~load_field
654 Removed: ~reps_id ~reps_field ?load_value ?reps_value ~reps_label
655 Removed: ?(hide_labels = false) () =
656 Removed: tag "div"
657 Removed: [ class_ "movement" ]
658 Removed: [
659 Removed: tag "p" [ class_ "exercise-name" ] [ txt "%s" name ];
660 Removed: input_row ?value:load_value ~placeholder:"kg" ~hide_label:hide_labels
661 Removed: ~invalid:(List.mem_assoc load_field errors)
662 Removed: ~input_id:load_id load_field "Load (kg)";
663 Removed: input_row ?value:reps_value ~placeholder:"reps" ~hide_label:hide_labels
664 Removed: ~invalid:(List.mem_assoc reps_field errors)
665 Removed: ~input_id:reps_id reps_field reps_label;
666 Removed: ]
667 Removed:
668 Removed: (* A shared heading row for a multi-exercise group: empty name cell, then the
669 Removed: Load and Reps column headings. Rendered once as cells of the fieldset grid so
670 Removed: the labels are not repeated above every input. *)
671 Removed: let exercise_fields_head ~reps_label () =
672 Removed: tag "div"
673 Removed: [ class_ "movement movement-head" ]
674 Removed: [
675 Removed: tag "span" [ class_ "exercise-name" ] [ txt "" ];
676 Removed: tag "span" [ class_ "col-heading" ] [ txt "Load (kg)" ];
677 Removed: tag "span" [ class_ "col-heading" ] [ txt "%s" reps_label ];
678 Removed: ]
679 Removed:
680 Removed: (* [movements] carries the record/correction field spec the controller built
681 Removed: from the slot's prescription: one row for a single exercise, two for a
682 Removed: pre-exhaust pair, each already pre-filled when correcting. [submit] names the
683 Removed: action. Load and reps of each movement are laid out side by side. *)
684 Removed: let form_for_stimulus request ~action_path ~slot
685 Removed: ~(movements : View_model.Workout.movement list) ~selected_extension
686 Removed: ?(enhanced = false) ?(submit = "Record") ~(errors : (string * string) list)
687 Removed: () =
688 Removed: let field_id field = Printf.sprintf "slot-%d-%s" slot field in
689 Removed: let selected = selected_extension in
690 Removed: (* A single-exercise slot shows one row with a visible "Reps to failure"
691 Removed: label. A pre-exhaust pair shows a shared column heading and hides the
692 Removed: per-row labels. *)
693 Removed: let hide_labels = List.length movements > 1 in
694 Removed: let head =
695 Removed: match movements with
696 Removed: | first :: _ :: _ ->
697 Removed: [
698 Removed: exercise_fields_head ~reps_label:first.View_model.Workout.reps_label
699 Removed: ();
700 Removed: ]
701 Removed: | _ -> []
702 Removed: in
703 Removed: let movement_of (m : View_model.Workout.movement) =
704 Removed: movement_row ~errors ~name:m.name ~load_id:(field_id m.load_field)
705 Removed: ~load_field:m.load_field ~reps_id:(field_id m.reps_field)
706 Removed: ~reps_field:m.reps_field ?load_value:m.load_value ?reps_value:m.reps_value
707 Removed: ~reps_label:m.reps_label ~hide_labels ()
708 Removed: in
709 Removed: let fields = head @ List.map movement_of movements in
710 Removed: (* The error rows follow the fields present, so a single-exercise slot reports
711 Removed: load/reps and a pre-exhaust slot reports the iso/comp fields. *)
712 Removed: let field_names =
713 Removed: List.concat_map
714 Removed: (fun (m : View_model.Workout.movement) -> [ m.load_field; m.reps_field ])
715 Removed: movements
716 Removed: @ [ "extension" ]
717 Removed: in
718 Removed: tag "form"
719 Removed: ([ action_path; post_form ]
720 Removed: @
721 Removed: if enhanced then [ Dream_html.attr "data-hito-workout-form" ]
722 Removed: else [ Dream_html.attr "data-hito-app-form" ])
723 Removed: [
724 Removed: Dream_html.csrf_tag request;
725 Removed: tag "fieldset"
726 Removed: [ class_ "logging-fieldset" ]
727 Removed: (tag "div"
728 Removed: [ class_ "logging-grid" ]
729 Removed: (fields
730 Removed: @ [
731 Removed: tag "div"
732 Removed: [ class_ "field field-ending" ]
733 Removed: [
734 Removed: tag "label"
735 Removed: [
736 Removed: Dream_html.string_attr "for" "%s" (field_id "extension");
737 Removed: ]
738 Removed: [ txt "Ending" ];
739 Removed: extension_select ~selected
740 Removed: ~invalid:(List.mem_assoc "extension" errors)
741 Removed: ~input_id:(field_id "extension") ();
742 Removed: ];
743 Removed: ])
744 Removed: :: List.concat_map
745 Removed: (fun field -> error_for ~input_id:(field_id field) field errors)
746 Removed: field_names
747 Removed: @ [
748 Removed: void "input"
749 Removed: [ type_ "submit"; Dream_html.string_attr "value" "%s" submit ];
750 Removed: ]);
751 Removed: ]
752 Removed:
753 Removed: let workout request ~viewer ?(errors = []) ?editing ?logging ~record_id
754 Removed: ~active_slot (vm : View_model.Workout.t) =
755 Removed: let enhanced = Option.is_none record_id in
756 Removed: (* A live workout view is itself the workout in progress, so the middle nav
757 Removed: action reads "Current workout". A saved-record view defers to the handler,
758 Removed: which knows whether a separate workout is in progress. *)
759 Removed: let logging = Option.value logging ~default:enhanced in
760 Removed: let slot_count = List.length vm.slots in
761 Removed: let filled_slots =
762 Removed: List.length
763 Removed: (List.filter
764 Removed: (fun (s : View_model.Workout.slot) ->
765 Removed: Option.is_some s.recorded_summary)
766 Removed: vm.slots)
767 Removed: in
768 Removed: let record_action slot =
769 Removed: match record_id with
770 Removed: | None -> action Routes.workout_slot slot
771 Removed: | Some record_id -> action Routes.record_slot record_id slot
772 Removed: in
773 Removed: let edit_action slot =
774 Removed: match record_id with
775 Removed: | None -> action Routes.workout_slot_edit slot
776 Removed: | Some record_id -> action Routes.record_slot_edit record_id slot
777 Removed: in
778 Removed: (* The early-workout notice. The override was already confirmed in a modal, so
779 Removed: on the workout screen this is informational only: a toast rather than a
780 Removed: component in the main content. It is a polite live region. The client shows
781 Removed: it briefly then dismisses it. Without script it stays visible as a quiet
782 Removed: fixed notice, so a no-JS visitor is still told. *)
783 Removed: let override_note =
784 Removed: if vm.overridden then
785 Removed: [
786 Removed: tag "div"
787 Removed: [
788 Removed: class_ "toast";
789 Removed: Dream_html.attr "data-hito-toast";
790 Removed: Dream_html.string_attr "role" "status";
791 Removed: Dream_html.string_attr "aria-live" "polite";
792 Removed: ]
793 Removed: [ txt "Begun before recovery finished." ];
794 Removed: ]
795 Removed: else []
796 Removed: in
797 Removed: (* The exercise selector: a dropdown naming every prescription slot. Choosing
798 Removed: an option opens that exercise below. The selector is a GET form so it works
799 Removed: without script — submitting navigates to the slot via a [?slot=] query, the
800 Removed: same contract the panel already reads. The client enhances it: it navigates
801 Removed: the moment the selection changes, so the submit button is a no-JS fallback.
802 Removed: A recorded slot is marked done in its label. *)
803 Removed: let select_action_base =
804 Removed: match record_id with None -> "/workout" | Some id -> "/logbook/" ^ id
805 Removed: in
806 Removed: let exercise_group =
807 Removed: let option_for (slot : View_model.Workout.slot) =
808 Removed: let done_ = Option.is_some slot.recorded_summary in
809 Removed: let label =
810 Removed: if done_ then Printf.sprintf "%d. %s (done)" (slot.index + 1) slot.label
811 Removed: else Printf.sprintf "%d. %s" (slot.index + 1) slot.label
812 Removed: in
813 Removed: let attrs = [ Dream_html.string_attr "value" "%d" slot.index ] in
814 Removed: let attrs =
815 Removed: if slot.index = active_slot then Dream_html.attr "selected" :: attrs
816 Removed: else attrs
817 Removed: in
818 Removed: tag "option" attrs [ txt "%s" label ]
819 Removed: in
820 Removed: let form_attrs =
821 Removed: [
822 Removed: Dream_html.string_attr "method" "get";
823 Removed: Dream_html.string_attr "action" "%s" select_action_base;
824 Removed: class_ "exercise-select";
825 Removed: ]
826 Removed: in
827 Removed: let form_attrs =
828 Removed: if enhanced then Dream_html.attr "data-hito-exercise-form" :: form_attrs
829 Removed: else form_attrs
830 Removed: in
831 Removed: tag "form" form_attrs
832 Removed: [
833 Removed: tag "label"
834 Removed: [ Dream_html.string_attr "for" "exercise-choice"; class_ "sr-only" ]
835 Removed: [ txt "Exercise" ];
836 Removed: tag "select"
837 Removed: [
838 Removed: name "slot";
839 Removed: id "exercise-choice";
840 Removed: Dream_html.attr "data-hito-exercise-select";
841 Removed: ]
842 Removed: (List.map option_for vm.slots);
843 Removed: void "input"
844 Removed: [
845 Removed: type_ "submit"; class_ "secondary exercise-select-go"; value "Open";
846 Removed: ];
847 Removed: ]
848 Removed: in
849 Removed: (* The panel for the active slot. It shows one exercise at a time: a recorded
850 Removed: slot renders its read-only summary and a pre-filled correction form (which
851 Removed: replaces the slot rather than adding volume). An outstanding slot renders
852 Removed: the record form. *)
853 Removed: let active_panel =
854 Removed: match List.nth_opt vm.slots active_slot with
855 Removed: | None ->
856 Removed: [
857 Removed: tag "p"
858 Removed: [ class_ "warn" ]
859 Removed: [ txt "That exercise is not part of this workout." ];
860 Removed: ]
861 Removed: | Some (slot : View_model.Workout.slot) ->
862 Removed: let errors = if editing = Some active_slot then errors else [] in
863 Removed: let heading = tag "h2" [] [ txt "%s" slot.label ] in
864 Removed: let body =
865 Removed: match slot.recorded_summary with
866 Removed: | Some summary ->
867 Removed: [
868 Removed: tag "p" [ class_ "eyebrow" ] [ txt "Recorded" ];
869 Removed: tag "p" [ class_ "done" ] [ txt "%s" summary ];
870 Removed: form_for_stimulus request ~action_path:(edit_action active_slot)
871 Removed: ~slot:active_slot ~movements:slot.movements
872 Removed: ~selected_extension:slot.selected_extension ~enhanced
873 Removed: ~submit:"Save correction" ~errors ();
874 Removed: ]
875 Removed: | None ->
876 Removed: [
877 Removed: form_for_stimulus request
878 Removed: ~action_path:(record_action active_slot)
879 Removed: ~slot:active_slot ~movements:slot.movements
880 Removed: ~selected_extension:slot.selected_extension ~enhanced ~errors
881 Removed: ();
882 Removed: ]
883 Removed: in
884 Removed: [ tag "section" [ class_ "slot-panel" ] (heading :: body) ]
885 Removed: in
886 Removed: let complete_note =
887 Removed: if vm.all_recorded then
888 Removed: [
889 Removed: tag "p"
890 Removed: [ class_ "done" ]
891 Removed: [ txt "Everything prescribed has been recorded." ];
892 Removed: ]
893 Removed: else []
894 Removed: in
895 Removed: (* A sticky timer bar for the workout in progress. It carries the workout's
896 Removed: start time as an epoch, and the client ticks the elapsed time from it.
897 Removed: Reading the true start on every render means an SPA content swap never
898 Removed: resets the count. Only the live workout shows it. *)
899 Removed: let timer_bar =
900 Removed: if enhanced then
901 Removed: let started = vm.started_at_unix in
902 Removed: [
903 Removed: tag "div"
904 Removed: [
905 Removed: class_ "workout-timer";
906 Removed: Dream_html.attr "data-hito-workout-timer";
907 Removed: Dream_html.string_attr "data-started" "%d" started;
908 Removed: ]
909 Removed: [
910 Removed: tag "span" [ class_ "workout-timer-label" ] [ txt "Elapsed" ];
911 Removed: tag "span"
912 Removed: [
913 Removed: class_ "workout-timer-value";
914 Removed: Dream_html.string_attr "aria-live" "off";
915 Removed: Dream_html.attr "data-hito-workout-timer-value";
916 Removed: ]
917 Removed: [ txt "0:00" ];
918 Removed: ];
919 Removed: ]
920 Removed: else []
921 Removed: in
922 Removed: let finish_section =
923 Removed: match record_id with
924 Removed: | Some _ -> []
925 Removed: | None ->
926 Removed: [
927 Removed: tag "div"
928 Removed: [ class_ "button-group" ]
929 Removed: [
930 Removed: tag "form"
931 Removed: [
932 Removed: action Routes.finish_workout;
933 Removed: post_form;
934 Removed: Dream_html.attr "data-hito-app-form";
935 Removed: ]
936 Removed: [
937 Removed: Dream_html.csrf_tag request;
938 Removed: void "input" [ type_ "submit"; value "Finish workout" ];
939 Removed: ];
940 Removed: (* Cancel is a native POST form. Without script, it submits
941 Removed: immediately. The client only opens the confirm dialog. Its
942 Removed: acceptance submits this form as a full document navigation so
943 Removed: the cancelled workout cannot leave a stale app shell behind. *)
944 Removed: tag "form"
945 Removed: [
946 Removed: action Routes.cancel_workout;
947 Removed: post_form;
948 Removed: Dream_html.attr "data-hito-confirm-form";
949 Removed: Dream_html.attr "data-hito-cancel-form";
950 Removed: ]
951 Removed: [
952 Removed: Dream_html.csrf_tag request;
953 Removed: void "input"
954 Removed: [
955 Removed: type_ "submit";
956 Removed: class_ "secondary";
957 Removed: value "Cancel logging";
958 Removed: Dream_html.attr "data-hito-confirm-cancel";
959 Removed: ];
960 Removed: ];
961 Removed: ];
962 Removed: (* The confirmation modal. Native dialog for built-in focus trapping
963 Removed: and Escape-to-close. The client opens it. "Cancel workout" submits
964 Removed: the cancel form, "Keep logging" closes it. *)
965 Removed: tag "dialog"
966 Removed: [
967 Removed: class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal";
968 Removed: ]
969 Removed: [
970 Removed: tag "h2" [] [ txt "Cancel this workout?" ];
971 Removed: tag "p" []
972 Removed: [
973 Removed: txt
974 Removed: "The workout is discarded and leaves no record, then you \
975 Removed: return Home.";
976 Removed: ];
977 Removed: tag "div"
978 Removed: [ class_ "button-group" ]
979 Removed: [
980 Removed: tag "button"
981 Removed: [
982 Removed: type_ "button";
983 Removed: class_ "secondary";
984 Removed: Dream_html.attr "data-hito-confirm-dismiss";
985 Removed: ]
986 Removed: [ txt "Keep logging" ];
987 Removed: tag "button"
988 Removed: [
989 Removed: type_ "button"; Dream_html.attr "data-hito-confirm-accept";
990 Removed: ]
991 Removed: [ txt "Cancel workout" ];
992 Removed: ];
993 Removed: ];
994 Removed: ]
995 Removed: in
996 Removed: html_page ~viewer ~request ~active:"workout" ~logging vm.name
997 Removed: [
998 Removed: tag "div"
999 Removed: (if enhanced then
1000 Removed: [
1001 Removed: Dream_html.attr "data-hito-workout";
1002 Removed: Dream_html.attr "data-hito-workout-content";
1003 Removed: ]
1004 Removed: else [])
1005 Removed: (timer_bar @ override_note
1006 Removed: @ [
1007 Removed: tag "h1" [] [ txt "%s" vm.name ];
1008 Removed: tag "p"
1009 Removed: [ class_ "ledger-meta" ]
1010 Removed: [ txt "%d of %d recorded." filled_slots slot_count ];
1011 Removed: exercise_group;
1012 Removed: ]
1013 Removed: @ active_panel @ complete_note @ finish_section
1014 Removed: @
1015 Removed: if enhanced then
1016 Removed: [
1017 Removed: tag "p"
1018 Removed: [
1019 Removed: Dream_html.string_attr "aria-live" "polite";
1020 Removed: Dream_html.attr "data-hito-workout-status";
1021 Removed: ]
1022 Removed: [];
1023 Removed: ]
1024 Removed: else []);
1025 Removed: ]
1026 Removed:
1027 Removed: (* The subjective feedback form. Radio groups report sleep, appetite,
1028 Removed: readiness, motivation, and difficulty against a personal baseline. Flags
1029 Removed: report pain, injury, and insufficient preparation. A field left blank reports
1030 Removed: nothing, so the trainee submits only what they mean to. Feedback is
1031 Removed: standalone — recorded from the Logbook at any time. *)
1032 Removed: type feedback_flow = { step : int; answers : (string * string) list }
1033 Removed:
1034 Removed: let feedback_factors =
1035 Removed: [
1036 Removed: ("sleep", "Sleep");
1037 Removed: ("appetite", "Appetite");
1038 Removed: ("readiness", "Readiness");
1039 Removed: ("motivation", "Motivation");
1040 Removed: ("difficulty", "Perceived difficulty");
1041 Removed: ]
1042 Removed:
1043 Removed: (* Each subjective metric uses five unselected radio buttons styled as inline
1044 Removed: buttons. The server shows one metric at a time. *)
1045 Removed: let feedback_level_buttons field label ~selected =
1046 Removed: let choice code text =
1047 Removed: tag "label"
1048 Removed: [ class_ "feedback-choice"; Dream_html.attr "data-hito-feedback-choice" ]
1049 Removed: [
1050 Removed: void "input"
1051 Removed: ([
1052 Removed: type_ "radio";
1053 Removed: Dream_html.string_attr "name" "choice";
1054 Removed: Dream_html.string_attr "value" "%s" code;
1055 Removed: Dream_html.string_attr "id" "%s-%s" field code;
1056 Removed: ]
1057 Removed: @
1058 Removed: if String.equal code selected then [ Dream_html.attr "checked" ]
1059 Removed: else []);
1060 Removed: tag "span" [] [ txt "%s" text ];
1061 Removed: ]
1062 Removed: in
1063 Removed: tag "div"
1064 Removed: [ class_ "feedback-group" ]
1065 Removed: [
1066 Removed: tag "p" [ class_ "feedback-group-label" ] [ txt "%s" label ];
1067 Removed: tag "div"
1068 Removed: [
1069 Removed: class_ "feedback-buttons";
1070 Removed: Dream_html.string_attr "role" "group";
1071 Removed: Dream_html.string_attr "aria-label" "%s" label;
1072 Removed: ]
1073 Removed: [
1074 Removed: choice "1" "1 — very poor";
1075 Removed: choice "2" "2";
1076 Removed: choice "3" "3";
1077 Removed: choice "4" "4";
1078 Removed: choice "5" "5 — very good";
1079 Removed: ];
1080 Removed: ]
1081 Removed:
1082 Removed: let feedback_flag field label =
1083 Removed: tag "label"
1084 Removed: [ class_ "feedback-flag" ]
1085 Removed: [
1086 Removed: void "input"
1087 Removed: [
1088 Removed: type_ "checkbox";
1089 Removed: Dream_html.string_attr "name" "%s" field;
1090 Removed: value "true";
1091 Removed: ];
1092 Removed: txt " %s" label;
1093 Removed: ]
1094 Removed:
1095 Removed: let feedback_progress step =
1096 Removed: let completed = min 5 (max 0 step) in
1097 Removed: tag "div"
1098 Removed: [ class_ "feedback-progress" ]
1099 Removed: [
1100 Removed: tag "progress"
1101 Removed: [
1102 Removed: Dream_html.string_attr "value" "%s" (string_of_int completed);
1103 Removed: Dream_html.string_attr "max" "5";
1104 Removed: Dream_html.string_attr "aria-label" "Feedback progress";
1105 Removed: ]
1106 Removed: [ txt "%d of 5 factors" completed ];
1107 Removed: tag "p" [ class_ "ledger-meta" ] [ txt "%d of 5 factors" completed ];
1108 Removed: ]
1109 Removed:
1110 Removed: let feedback_action_button ~action ~label ?(secondary = false) () =
1111 Removed: tag "button"
1112 Removed: ([
1113 Removed: type_ "submit";
1114 Removed: name "action";
1115 Removed: value action;
1116 Removed: Dream_html.attr "data-hito-feedback-action";
1117 Removed: ]
1118 Removed: @ if secondary then [ class_ "secondary" ] else [])
1119 Removed: [ txt "%s" label ]
1120 Removed:
1121 Removed: let feedback_flow_form request flow =
1122 Removed: let step = min 5 (max 0 flow.step) in
1123 Removed: let fields =
1124 Removed: [
1125 Removed: void "input"
1126 Removed: [
1127 Removed: type_ "hidden";
1128 Removed: name "step";
1129 Removed: Dream_html.string_attr "value" "%s" (string_of_int step);
1130 Removed: ];
1131 Removed: void "input"
1132 Removed: [
1133 Removed: type_ "hidden";
1134 Removed: name "action";
1135 Removed: Dream_html.string_attr "value" "";
1136 Removed: Dream_html.attr "data-hito-feedback-action-value";
1137 Removed: ];
1138 Removed: Dream_html.csrf_tag request;
1139 Removed: ]
1140 Removed: in
1141 Removed: let body =
1142 Removed: if step < 5 then
1143 Removed: let field, label = List.nth feedback_factors step in
1144 Removed: fields
1145 Removed: @ [
1146 Removed: tag "h3" [] [ txt "%s" label ];
1147 Removed: feedback_level_buttons field label
1148 Removed: ~selected:
1149 Removed: (Option.value (List.assoc_opt field flow.answers) ~default:"");
1150 Removed: feedback_progress (step + 1);
1151 Removed: tag "div"
1152 Removed: [ class_ "feedback-actions" ]
1153 Removed: ((if step > 0 then
1154 Removed: let _, previous_label = List.nth feedback_factors (step - 1) in
1155 Removed: [
1156 Removed: feedback_action_button ~action:"back"
1157 Removed: ~label:(Printf.sprintf "Back to %s" previous_label)
1158 Removed: ~secondary:true ();
1159 Removed: ]
1160 Removed: else [])
1161 Removed: @ [
1162 Removed: feedback_action_button ~action:"skip" ~label:"Skip this factor"
1163 Removed: ~secondary:true ();
1164 Removed: ]);
1165 Removed: ]
1166 Removed: else
1167 Removed: fields
1168 Removed: @ [
1169 Removed: tag "h3" [] [ txt "Anything else?" ];
1170 Removed: tag "p" []
1171 Removed: [ txt "Add an optional note about pain, injury, or preparation." ];
1172 Removed: tag "div"
1173 Removed: [ class_ "field" ]
1174 Removed: [
1175 Removed: feedback_flag "pain" "Pain";
1176 Removed: feedback_flag "injury" "Injury";
1177 Removed: feedback_flag "preparation" "Preparation was insufficient";
1178 Removed: ];
1179 Removed: feedback_progress 5;
1180 Removed: tag "div"
1181 Removed: [ class_ "feedback-actions" ]
1182 Removed: [
1183 Removed: feedback_action_button ~action:"back"
1184 Removed: ~label:"Back to Perceived difficulty" ~secondary:true ();
1185 Removed: feedback_action_button ~action:"save" ~label:"Submit feedback" ();
1186 Removed: ];
1187 Removed: ]
1188 Removed: in
1189 Removed: tag "form"
1190 Removed: [
1191 Removed: action Routes.feedback;
1192 Removed: post_form;
1193 Removed: class_ "feedback-form";
1194 Removed: Dream_html.attr "data-hito-feedback-form";
1195 Removed: Dream_html.attr "data-hito-app-form";
1196 Removed: ]
1197 Removed: [ tag "fieldset" [] body ]
1198 Removed:
1199 Removed: let feedback_modal request ~flow ~open_ =
1200 Removed: tag "dialog"
1201 Removed: ([ class_ "feedback-modal"; Dream_html.attr "data-hito-feedback-modal" ]
1202 Removed: @ if open_ then [ Dream_html.attr "open" ] else [])
1203 Removed: [
1204 Removed: tag "div"
1205 Removed: [ class_ "feedback-modal-content" ]
1206 Removed: [
1207 Removed: tag "div"
1208 Removed: [ class_ "feedback-modal-heading" ]
1209 Removed: [
1210 Removed: tag "h2" [] [ txt "How are you feeling?" ];
1211 Removed: (* Close cancels the flow: it submits the cancel form, so the
1212 Removed: partial progress is discarded rather than kept for later. *)
1213 Removed: tag "form"
1214 Removed: [
1215 Removed: action Routes.cancel_feedback;
1216 Removed: post_form;
1217 Removed: class_ "feedback-close-form";
1218 Removed: Dream_html.attr "data-hito-app-form";
1219 Removed: ]
1220 Removed: [
1221 Removed: Dream_html.csrf_tag request;
1222 Removed: tag "button"
1223 Removed: [
1224 Removed: type_ "submit";
1225 Removed: class_ "feedback-modal-close";
1226 Removed: Dream_html.string_attr "aria-label" "Close feedback";
1227 Removed: ]
1228 Removed: [ txt "×" ];
1229 Removed: ];
1230 Removed: ];
1231 Removed: feedback_flow_form request flow;
1232 Removed: ];
1233 Removed: ]
1234 Removed:
1235 Removed: (* A small inline-SVG line chart of leveled feedback over time, one polyline per
1236 Removed: factor. No script and no dependency: the logbook records, and this only draws
1237 Removed: what it already holds. Shown once at least two reports carry a leveled
1238 Removed: factor, since a single point is not a trend. *)
1239 Removed: let feedback_graph (reports : View_model.Feedback.report list) =
1240 Removed: let chronological =
1241 Removed: List.sort
1242 Removed: (fun (a : View_model.Feedback.report) (b : View_model.Feedback.report) ->
1243 Removed: Int.compare a.at_unix b.at_unix)
1244 Removed: reports
1245 Removed: in
1246 Removed: let count = List.length chronological in
1247 Removed: let score_of field (report : View_model.Feedback.report) =
1248 Removed: match List.assoc_opt field report.factor_scores with
1249 Removed: | Some s -> s
1250 Removed: | None -> None
1251 Removed: in
1252 Removed: let series =
1253 Removed: List.map
1254 Removed: (fun (field, label) ->
1255 Removed: ( label,
1256 Removed: List.mapi (fun i report -> (i, score_of field report)) chronological
1257 Removed: ))
1258 Removed: feedback_factors
1259 Removed: in
1260 Removed: (* A factor charts only if it holds at least two scored points. *)
1261 Removed: let plottable (_, points) =
1262 Removed: List.length (List.filter (fun (_, s) -> Option.is_some s) points) >= 2
1263 Removed: in
1264 Removed: let series = List.filter plottable series in
1265 Removed: if count < 2 || series = [] then []
1266 Removed: else
1267 Removed: let width = 480 and height = 180 in
1268 Removed: let pad_left = 28 and pad_right = 12 and pad_top = 12 and pad_bottom = 24 in
1269 Removed: let plot_w = width - pad_left - pad_right in
1270 Removed: let plot_h = height - pad_top - pad_bottom in
1271 Removed: let x_of i =
1272 Removed: if count = 1 then pad_left + (plot_w / 2)
1273 Removed: else pad_left + (i * plot_w / (count - 1))
1274 Removed: in
1275 Removed: (* Score 1..5 maps low-to-high, so 5 sits at the top. *)
1276 Removed: let y_of score = pad_top + ((5 - score) * plot_h / 4) in
1277 Removed: let polyline label points =
1278 Removed: let coords =
1279 Removed: List.filter_map
1280 Removed: (fun (i, s) ->
1281 Removed: Option.map (fun s -> Printf.sprintf "%d,%d" (x_of i) (y_of s)) s)
1282 Removed: points
1283 Removed: in
1284 Removed: tag "polyline"
1285 Removed: [
1286 Removed: class_ "feedback-graph-line";
1287 Removed: Dream_html.string_attr "points" "%s" (String.concat " " coords);
1288 Removed: Dream_html.string_attr "fill" "none";
1289 Removed: Dream_html.string_attr "data-factor" "%s" label;
1290 Removed: ]
1291 Removed: []
1292 Removed: in
1293 Removed: let axis =
1294 Removed: List.map
1295 Removed: (fun score ->
1296 Removed: let y = y_of score in
1297 Removed: tag "text"
1298 Removed: [
1299 Removed: class_ "feedback-graph-tick";
1300 Removed: Dream_html.string_attr "x" "%d" (pad_left - 6);
1301 Removed: Dream_html.string_attr "y" "%d" (y + 3);
1302 Removed: Dream_html.string_attr "text-anchor" "end";
1303 Removed: ]
1304 Removed: [ txt "%d" score ])
1305 Removed: [ 1; 2; 3; 4; 5 ]
1306 Removed: in
1307 Removed: let legend =
1308 Removed: tag "ul"
1309 Removed: [ class_ "feedback-graph-legend" ]
1310 Removed: (List.map
1311 Removed: (fun (label, _) ->
1312 Removed: tag "li"
1313 Removed: [ Dream_html.string_attr "data-factor" "%s" label ]
1314 Removed: [ txt "%s" label ])
1315 Removed: series)
1316 Removed: in
1317 Removed: [
1318 Removed: tag "figure"
1319 Removed: [ class_ "feedback-graph" ]
1320 Removed: [
1321 Removed: tag "figcaption" [] [ txt "Feedback over time" ];
1322 Removed: tag "svg"
1323 Removed: [
1324 Removed: class_ "feedback-graph-svg";
1325 Removed: Dream_html.string_attr "viewBox" "0 0 %d %d" width height;
1326 Removed: Dream_html.string_attr "role" "img";
1327 Removed: Dream_html.string_attr "aria-label"
1328 Removed: "Subjective feedback scores over time, one line per factor";
1329 Removed: ]
1330 Removed: (axis
1331 Removed: @ List.map (fun (label, points) -> polyline label points) series);
1332 Removed: legend;
1333 Removed: ];
1334 Removed: ]
1335 Removed:
1336 Removed: let feedback_list (reports : View_model.Feedback.report list) =
1337 Removed: if reports = [] then []
1338 Removed: else
1339 Removed: [
1340 Removed: tag "ul"
1341 Removed: [ class_ "feedback-list" ]
1342 Removed: (List.map
1343 Removed: (fun (report : View_model.Feedback.report) ->
1344 Removed: let signals = String.concat ", " report.signals in
1345 Removed: tag "li" []
1346 Removed: [ txt "%s" (if signals = "" then "No signals" else signals) ])
1347 Removed: reports);
1348 Removed: ]
1349 Removed:
1350 Removed: let logbook request ?(logging = false) ~viewer ?(feedback = [])
1351 Removed: ?(suggest_feedback = false) ?feedback_flow ?(feedback_open = false) records
1352 Removed: =
1353 Removed: let feedback_flow =
1354 Removed: Option.value feedback_flow ~default:{ step = 0; answers = [] }
1355 Removed: in
1356 Removed: html_page ~viewer ~request ~active:"logbook" ~logging "Logbook"
1357 Removed: [
1358 Removed: tag "h1" [] [ txt "Logbook" ];
1359 Removed: (if records = [] then tag "p" [] [ txt "Nothing logged yet." ]
1360 Removed: else
1361 Removed: tag "ul" []
1362 Removed: (List.map
1363 Removed: (fun (entry : View_model.Logbook_entry.t) ->
1364 Removed: tag "li" []
1365 Removed: [
1366 Removed: tag "a"
1367 Removed: [
1368 Removed: href Routes.record entry.id;
1369 Removed: Dream_html.attr "data-hito-app-link";
1370 Removed: ]
1371 Removed: [
1372 Removed: txt "%s — %d stimuli" entry.workout_name
1373 Removed: entry.stimuli_count;
1374 Removed: ];
1375 Removed: tag "p"
1376 Removed: [ class_ "done" ]
1377 Removed: [
1378 Removed: txt "%s"
1379 Removed: (if entry.complete then "complete" else "incomplete");
1380 Removed: ];
1381 Removed: ])
1382 Removed: records));
1383 Removed: tag "section"
1384 Removed: [ class_ "feedback-section" ]
1385 Removed: ([
1386 Removed: tag "h2" [] [ txt "How are you feeling?" ];
1387 Removed: (if suggest_feedback then
1388 Removed: tag "p"
1389 Removed: [ class_ "eyebrow" ]
1390 Removed: [ txt "Workout saved — add feedback while it is fresh." ]
1391 Removed: else txt "");
1392 Removed: tag "p" []
1393 Removed: [
1394 Removed: txt
1395 Removed: "Report each factor in turn. Skip any factor or the full flow.";
1396 Removed: ];
1397 Removed: tag "a"
1398 Removed: [
1399 Removed: class_ "feedback-toggle";
1400 Removed: Dream_html.string_attr "href" "/logbook?feedback=start";
1401 Removed: Dream_html.attr "data-hito-feedback-open";
1402 Removed: ]
1403 Removed: [ txt "Record feedback" ];
1404 Removed: feedback_modal request ~flow:feedback_flow ~open_:feedback_open;
1405 Removed: ]
1406 Removed: @ feedback_graph feedback @ feedback_list feedback);
1407 Removed: ]
1408 Removed:
1409 Removed: (* The authenticated application feedback page. It keeps writing and reviewing
1410 Removed: feedback in separate tabs, while both tabs remain plain links for no-script
1411 Removed: use and progressive enhancement. *)
1412 Removed: let app_feedback_tab ~selected ~tab ~label =
1413 Removed: let path =
1414 Removed: match tab with
1415 Removed: | `Write -> "/app-feedback?tab=write"
1416 Removed: | `Submitted -> "/app-feedback?tab=submitted"
1417 Removed: in
1418 Removed: let attrs =
1419 Removed: [
1420 Removed: class_ "feedback-tab";
1421 Removed: Dream_html.string_attr "href" "%s" path;
1422 Removed: Dream_html.attr "data-hito-app-link";
1423 Removed: ]
1424 Removed: in
1425 Removed: let attrs =
1426 Removed: if selected = tab then Dream_html.string_attr "aria-current" "page" :: attrs
1427 Removed: else attrs
1428 Removed: in
1429 Removed: tag "a" attrs [ txt "%s" label ]
1430 Removed:
1431 Removed: let app_feedback_form request ?error () =
1432 Removed: let error_block =
1433 Removed: match error with
1434 Removed: | None -> []
1435 Removed: | Some message ->
1436 Removed: [
1437 Removed: tag "p"
1438 Removed: [ class_ "warn"; Dream_html.string_attr "role" "alert" ]
1439 Removed: [ txt "%s" message ];
1440 Removed: ]
1441 Removed: in
1442 Removed: tag "form"
1443 Removed: [
1444 Removed: action Routes.submit_app_feedback;
1445 Removed: post_form;
1446 Removed: class_ "app-feedback-form";
1447 Removed: Dream_html.attr "data-hito-app-form";
1448 Removed: ]
1449 Removed: (Dream_html.csrf_tag request
1450 Removed: :: tag "label"
1451 Removed: [ Dream_html.string_attr "for" "app-feedback-message" ]
1452 Removed: [ txt "Your feedback" ]
1453 Removed: :: tag "textarea"
1454 Removed: [
1455 Removed: name "message";
1456 Removed: id "app-feedback-message";
1457 Removed: Dream_html.string_attr "rows" "8";
1458 Removed: required;
1459 Removed: Dream_html.string_attr "placeholder"
1460 Removed: "Tell us what works well or what needs attention.";
1461 Removed: ]
1462 Removed: []
1463 Removed: :: error_block
1464 Removed: @ [ void "input" [ type_ "submit"; value "Submit feedback" ] ])
1465 Removed:
1466 Removed: let app_feedback_vote_form request (report : View_model.App_feedback.t) =
1467 Removed: if report.viewer_owns then
1468 Removed: tag "p" [ class_ "app-feedback-own" ] [ txt "Your feedback" ]
1469 Removed: else
1470 Removed: let label =
1471 Removed: if report.viewer_upvoted then "Upvoted"
1472 Removed: else Printf.sprintf "Upvote (%d)" report.upvotes
1473 Removed: in
1474 Removed: let attrs =
1475 Removed: [
1476 Removed: action Routes.upvote_app_feedback report.id;
1477 Removed: post_form;
1478 Removed: class_ "app-feedback-vote";
1479 Removed: Dream_html.attr "data-hito-app-form";
1480 Removed: ]
1481 Removed: in
1482 Removed: let attrs =
1483 Removed: if report.viewer_upvoted then Dream_html.attr "disabled" :: attrs
1484 Removed: else attrs
1485 Removed: in
1486 Removed: tag "form" attrs
1487 Removed: [
1488 Removed: Dream_html.csrf_tag request;
1489 Removed: void "input"
1490 Removed: [ type_ "submit"; Dream_html.string_attr "value" "%s" label ];
1491 Removed: ]
1492 Removed:
1493 Removed: let app_feedback_edit_form request (report : View_model.App_feedback.t) =
1494 Removed: tag "form"
1495 Removed: [
1496 Removed: action Routes.edit_app_feedback report.id;
1497 Removed: post_form;
1498 Removed: class_ "app-feedback-edit-form";
1499 Removed: Dream_html.attr "data-hito-app-form";
1500 Removed: ]
1501 Removed: [
1502 Removed: Dream_html.csrf_tag request;
1503 Removed: tag "label"
1504 Removed: [ Dream_html.string_attr "for" "app-feedback-edit-%s" report.id ]
1505 Removed: [ txt "Edit feedback" ];
1506 Removed: tag "textarea"
1507 Removed: [
1508 Removed: name "message";
1509 Removed: Dream_html.string_attr "id" "app-feedback-edit-%s" report.id;
1510 Removed: Dream_html.string_attr "rows" "4";
1511 Removed: required;
1512 Removed: ]
1513 Removed: [ txt "%s" report.message ];
1514 Removed: void "input" [ type_ "submit"; value "Save edit" ];
1515 Removed: ]
1516 Removed:
1517 Removed: let app_feedback_remove_form request (report : View_model.App_feedback.t) =
1518 Removed: tag "form"
1519 Removed: [
1520 Removed: action Routes.remove_app_feedback report.id;
1521 Removed: post_form;
1522 Removed: class_ "app-feedback-remove-form";
1523 Removed: Dream_html.attr "data-hito-app-form";
1524 Removed: ]
1525 Removed: [
1526 Removed: Dream_html.csrf_tag request;
1527 Removed: void "input" [ type_ "submit"; value "Remove feedback" ];
1528 Removed: ]
1529 Removed:
1530 Removed: let app_feedback_actions request (report : View_model.App_feedback.t) =
1531 Removed: if report.viewer_owns then
1532 Removed: tag "div"
1533 Removed: [ class_ "app-feedback-actions" ]
1534 Removed: [
1535 Removed: app_feedback_edit_form request report;
1536 Removed: app_feedback_remove_form request report;
1537 Removed: ]
1538 Removed: else
1539 Removed: tag "div"
1540 Removed: [ class_ "app-feedback-actions" ]
1541 Removed: [ app_feedback_vote_form request report ]
1542 Removed:
1543 Removed: let app_feedback request ?(logging = false) ~viewer ?(tab = `Write) ?error
1544 Removed: reports =
1545 Removed: let panel =
1546 Removed: match tab with
1547 Removed: | `Write ->
1548 Removed: [
1549 Removed: tag "h2" [] [ txt "Write feedback" ];
1550 Removed: tag "p" []
1551 Removed: [ txt "Tell us about your experience using the application." ];
1552 Removed: app_feedback_form request ?error ();
1553 Removed: ]
1554 Removed: | `Submitted ->
1555 Removed: [
1556 Removed: tag "h2" [] [ txt "Submitted feedback" ];
1557 Removed: (if reports = [] then
1558 Removed: tag "p" []
1559 Removed: [ txt "You have not submitted any application feedback." ]
1560 Removed: else
1561 Removed: tag "ul"
1562 Removed: [ class_ "app-feedback-list" ]
1563 Removed: (List.map
1564 Removed: (fun (report : View_model.App_feedback.t) ->
1565 Removed: tag "li" []
1566 Removed: [
1567 Removed: tag "p"
1568 Removed: [ class_ "app-feedback-author" ]
1569 Removed: [
1570 Removed: txt "%s — %d contributions" report.author
1571 Removed: report.contributions;
1572 Removed: ];
1573 Removed: tag "time"
1574 Removed: [
1575 Removed: class_ "app-feedback-time";
1576 Removed: Dream_html.string_attr "datetime" "%s"
1577 Removed: report.submitted_at;
1578 Removed: ]
1579 Removed: [ txt "%s" report.submitted_at ];
1580 Removed: tag "p"
1581 Removed: [ class_ "app-feedback-message" ]
1582 Removed: [ txt "%s" report.message ];
1583 Removed: tag "p"
1584 Removed: [ class_ "app-feedback-upvotes" ]
1585 Removed: [ txt "Upvotes: %d" report.upvotes ];
1586 Removed: app_feedback_actions request report;
1587 Removed: ])
1588 Removed: reports));
1589 Removed: ]
1590 Removed: in
1591 Removed: html_page ~viewer ~request ~active:"app-feedback" ~logging "App feedback"
1592 Removed: [
1593 Removed: tag "div" []
1594 Removed: ([
1595 Removed: tag "h1" [] [ txt "App feedback" ];
1596 Removed: tag "p" [] [ txt "Help us improve hito by sharing your experience." ];
1597 Removed: tag "nav"
1598 Removed: [
1599 Removed: class_ "feedback-tabs";
1600 Removed: Dream_html.string_attr "aria-label" "App feedback tabs";
1601 Removed: ]
1602 Removed: [
1603 Removed: app_feedback_tab ~selected:tab ~tab:`Write
1604 Removed: ~label:"Write feedback";
1605 Removed: app_feedback_tab ~selected:tab ~tab:`Submitted
1606 Removed: ~label:"Submitted feedback";
1607 Removed: ];
1608 Removed: ]
1609 Removed: @ panel);
1610 Removed: ]
1611 Removed:
1612 Removed: (* The account profile page. It names the signed-in trainee and offers two
1613 Removed: independent forms: rename the account, and change the password. Each form
1614 Removed: reports its own error inline. [notice] confirms a successful change. Both are
1615 Removed: plain authenticated app-forms, so they work with or without script. *)
1616 Removed: let profile request ?(logging = false) ~viewer ?username_error ?password_error
1617 Removed: ?notice () =
1618 Removed: let notice_block =
1619 Removed: match notice with
1620 Removed: | Some message -> [ tag "p" [ class_ "notice" ] [ txt "%s" message ] ]
1621 Removed: | None -> []
1622 Removed: in
1623 Removed: let error_block = function
1624 Removed: | Some message ->
1625 Removed: [
1626 Removed: tag "p"
1627 Removed: [ class_ "warn"; Dream_html.string_attr "role" "alert" ]
1628 Removed: [ txt "%s" message ];
1629 Removed: ]
1630 Removed: | None -> []
1631 Removed: in
1632 Removed: html_page ~viewer ~request ~active:"profile" ~logging "Profile"
1633 Removed: ([ tag "h1" [] [ txt "Profile" ] ]
1634 Removed: @ notice_block
1635 Removed: @ [
1636 Removed: (* Username: show the current value and a button that opens the edit
1637 Removed: modal. The modal holds the rename form. A no-script client, or an
1638 Removed: error re-render, opens the dialog through the [open] attribute so the
1639 Removed: form stays reachable and its inline error shows. *)
1640 Removed: tag "section"
1641 Removed: [ class_ "profile-section" ]
1642 Removed: [
1643 Removed: tag "h2" [] [ txt "Username" ];
1644 Removed: tag "div"
1645 Removed: [ class_ "profile-value" ]
1646 Removed: [
1647 Removed: tag "span" [ class_ "profile-value-label" ] [ txt "Username" ];
1648 Removed: tag "span"
1649 Removed: [ class_ "profile-value-text" ]
1650 Removed: [ txt "%s" viewer.View_model.Viewer.username ];
1651 Removed: tag "button"
1652 Removed: [
1653 Removed: type_ "button";
1654 Removed: class_ "profile-edit";
1655 Removed: Dream_html.string_attr "data-hito-dialog-open"
1656 Removed: "username-dialog";
1657 Removed: ]
1658 Removed: [ txt "Change username" ];
1659 Removed: ];
1660 Removed: tag "dialog"
1661 Removed: ([
1662 Removed: Dream_html.string_attr "id" "username-dialog";
1663 Removed: class_ "profile-dialog";
1664 Removed: ]
1665 Removed: @
1666 Removed: if Option.is_some username_error then [ Dream_html.attr "open" ]
1667 Removed: else [])
1668 Removed: [
1669 Removed: tag "div"
1670 Removed: [ class_ "profile-dialog-content" ]
1671 Removed: ([
1672 Removed: tag "h3" [] [ txt "Change username" ];
1673 Removed: tag "form"
1674 Removed: [
1675 Removed: action Routes.profile_username;
1676 Removed: post_form;
1677 Removed: class_ "profile-form";
1678 Removed: Dream_html.attr "data-hito-app-form";
1679 Removed: ]
1680 Removed: [
1681 Removed: Dream_html.csrf_tag request;
1682 Removed: tag "div"
1683 Removed: [ class_ "field" ]
1684 Removed: [
1685 Removed: tag "label"
1686 Removed: [ Dream_html.string_attr "for" "username" ]
1687 Removed: [ txt "Username" ];
1688 Removed: void "input"
1689 Removed: [
1690 Removed: type_ "text";
1691 Removed: name "username";
1692 Removed: Dream_html.string_attr "id" "username";
1693 Removed: Dream_html.string_attr "value" "%s"
1694 Removed: viewer.View_model.Viewer.username;
1695 Removed: required;
1696 Removed: ];
1697 Removed: ];
1698 Removed: tag "div"
1699 Removed: [ class_ "profile-dialog-actions" ]
1700 Removed: [
1701 Removed: tag "button"
1702 Removed: [
1703 Removed: type_ "button";
1704 Removed: class_ "secondary";
1705 Removed: Dream_html.attr "data-hito-dialog-close";
1706 Removed: ]
1707 Removed: [ txt "Cancel" ];
1708 Removed: void "input"
1709 Removed: [ type_ "submit"; value "Change username" ];
1710 Removed: ];
1711 Removed: ];
1712 Removed: ]
1713 Removed: @ error_block username_error);
1714 Removed: ];
1715 Removed: ];
1716 Removed: (* Password: a button opens the change-password modal. *)
1717 Removed: tag "section"
1718 Removed: [ class_ "profile-section" ]
1719 Removed: [
1720 Removed: tag "h2" [] [ txt "Password" ];
1721 Removed: tag "div"
1722 Removed: [ class_ "profile-value" ]
1723 Removed: [
1724 Removed: tag "span" [ class_ "profile-value-label" ] [ txt "Password" ];
1725 Removed: tag "span" [ class_ "profile-value-text" ] [ txt "••••••••" ];
1726 Removed: tag "button"
1727 Removed: [
1728 Removed: type_ "button";
1729 Removed: class_ "profile-edit";
1730 Removed: Dream_html.string_attr "data-hito-dialog-open"
1731 Removed: "password-dialog";
1732 Removed: ]
1733 Removed: [ txt "Change password" ];
1734 Removed: ];
1735 Removed: tag "dialog"
1736 Removed: ([
1737 Removed: Dream_html.string_attr "id" "password-dialog";
1738 Removed: class_ "profile-dialog";
1739 Removed: ]
1740 Removed: @
1741 Removed: if Option.is_some password_error then [ Dream_html.attr "open" ]
1742 Removed: else [])
1743 Removed: [
1744 Removed: tag "div"
1745 Removed: [ class_ "profile-dialog-content" ]
1746 Removed: ([
1747 Removed: tag "h3" [] [ txt "Change password" ];
1748 Removed: tag "form"
1749 Removed: [
1750 Removed: action Routes.profile_password;
1751 Removed: post_form;
1752 Removed: class_ "profile-form";
1753 Removed: Dream_html.attr "data-hito-app-form";
1754 Removed: ]
1755 Removed: [
1756 Removed: Dream_html.csrf_tag request;
1757 Removed: tag "div"
1758 Removed: [ class_ "field" ]
1759 Removed: [
1760 Removed: tag "label"
1761 Removed: [ Dream_html.string_attr "for" "current" ]
1762 Removed: [ txt "Current password" ];
1763 Removed: void "input"
1764 Removed: [
1765 Removed: type_ "password";
1766 Removed: name "current";
1767 Removed: Dream_html.string_attr "id" "current";
1768 Removed: required;
1769 Removed: ];
1770 Removed: ];
1771 Removed: tag "div"
1772 Removed: [ class_ "field" ]
1773 Removed: [
1774 Removed: tag "label"
1775 Removed: [ Dream_html.string_attr "for" "next" ]
1776 Removed: [ txt "New password" ];
1777 Removed: void "input"
1778 Removed: [
1779 Removed: type_ "password";
1780 Removed: name "next";
1781 Removed: Dream_html.string_attr "id" "next";
1782 Removed: required;
1783 Removed: ];
1784 Removed: ];
1785 Removed: tag "div"
1786 Removed: [ class_ "profile-dialog-actions" ]
1787 Removed: [
1788 Removed: tag "button"
1789 Removed: [
1790 Removed: type_ "button";
1791 Removed: class_ "secondary";
1792 Removed: Dream_html.attr "data-hito-dialog-close";
1793 Removed: ]
1794 Removed: [ txt "Cancel" ];
1795 Removed: void "input"
1796 Removed: [ type_ "submit"; value "Change password" ];
1797 Removed: ];
1798 Removed: ];
1799 Removed: ]
1800 Removed: @ error_block password_error);
1801 Removed: ];
1802 Removed: ];
1803 Removed: (* Import: a multipart form uploads a native Hevy CSV export. The file
1804 Removed: field is [csv], the wall-clock offset is [utc_offset_seconds], and an
1805 Removed: optional checkbox overrides duplicate-source rejection. The form is a
1806 Removed: plain multipart POST with its CSRF token, so it needs no script. *)
1807 Removed: tag "section"
1808 Removed: [ class_ "profile-section" ]
1809 Removed: [
1810 Removed: tag "h2" [] [ txt "Import" ];
1811 Removed: tag "form"
1812 Removed: [
1813 Removed: action Routes.import_upload;
1814 Removed: post_form;
1815 Removed: Dream_html.string_attr "enctype" "multipart/form-data";
1816 Removed: class_ "profile-form";
1817 Removed: ]
1818 Removed: [
1819 Removed: Dream_html.csrf_tag request;
1820 Removed: tag "div"
1821 Removed: [ class_ "field" ]
1822 Removed: [
1823 Removed: tag "label"
1824 Removed: [ Dream_html.string_attr "for" "csv" ]
1825 Removed: [ txt "Hevy CSV export" ];
1826 Removed: void "input"
1827 Removed: [
1828 Removed: type_ "file";
1829 Removed: name "csv";
1830 Removed: Dream_html.string_attr "id" "csv";
1831 Removed: Dream_html.string_attr "accept" ".csv,text/csv";
1832 Removed: required;
1833 Removed: ];
1834 Removed: ];
1835 Removed: tag "div"
1836 Removed: [ class_ "field" ]
1837 Removed: [
1838 Removed: tag "label"
1839 Removed: [ Dream_html.string_attr "for" "utc_offset_seconds" ]
1840 Removed: [ txt "UTC offset in seconds" ];
1841 Removed: void "input"
1842 Removed: [
1843 Removed: type_ "number";
1844 Removed: name "utc_offset_seconds";
1845 Removed: Dream_html.string_attr "id" "utc_offset_seconds";
1846 Removed: Dream_html.string_attr "value" "0";
1847 Removed: required;
1848 Removed: ];
1849 Removed: ];
1850 Removed: tag "div"
1851 Removed: [ class_ "field" ]
1852 Removed: [
1853 Removed: void "input"
1854 Removed: [
1855 Removed: type_ "checkbox";
1856 Removed: name "allow_duplicate";
1857 Removed: Dream_html.string_attr "id" "allow_duplicate";
1858 Removed: value "true";
1859 Removed: ];
1860 Removed: tag "label"
1861 Removed: [ Dream_html.string_attr "for" "allow_duplicate" ]
1862 Removed: [ txt "Import even if this export was imported before" ];
1863 Removed: ];
1864 Removed: void "input" [ type_ "submit"; value "Upload import" ];
1865 Removed: ];
1866 Removed: ];
1867 Removed: ])
1868 Removed:
1869 Removed: (* The settings page. It currently stores the user's light or dark theme choice
1870 Removed: in the session. *)
1871 Removed: let settings request ?(logging = false) ~viewer ~theme () =
1872 Removed: let next_theme, label =
1873 Removed: match theme with
1874 Removed: | "dark" -> ("light", "Use light theme")
1875 Removed: | _ -> ("dark", "Use dark theme")
1876 Removed: in
1877 Removed: html_page ~viewer ~request ~active:"settings" ~logging "Settings"
1878 Removed: [
1879 Removed: tag "h1" [] [ txt "Settings" ];
1880 Removed: tag "section"
1881 Removed: [ class_ "settings-section" ]
1882 Removed: [
1883 Removed: tag "h2" [] [ txt "Theme" ];
1884 Removed: tag "p" [] [ txt "Choose a light or dark theme for this session." ];
1885 Removed: tag "form"
1886 Removed: [ action Routes.settings_theme; post_form; class_ "settings-form" ]
1887 Removed: [
1888 Removed: Dream_html.csrf_tag request;
1889 Removed: void "input"
1890 Removed: [
1891 Removed: type_ "hidden";
1892 Removed: name "theme";
1893 Removed: Dream_html.string_attr "value" "%s" next_theme;
1894 Removed: ];
1895 Removed: void "input"
1896 Removed: [ type_ "submit"; Dream_html.string_attr "value" "%s" label ];
1897 Removed: ];
1898 Removed: ];
1899 Removed: ]
1900 Removed:
1901 Removed: (* The review of a provisional import batch. It renders the parse warnings, then
1902 Removed: each workout with its sets, blockers, and forms. Every form is a plain POST
1903 Removed: carrying its CSRF token, so cleaning and promotion need no script. The page
1904 Removed: names only the view model: the controller has already phrased set types,
1905 Removed: blockers, and warnings, so no core entity crosses the boundary here. *)
1906 Removed: let import_review request ~viewer (vm : View_model.Import_review.t) =
1907 Removed: let warnings_block =
1908 Removed: match vm.View_model.Import_review.warnings with
1909 Removed: | [] -> []
1910 Removed: | warnings ->
1911 Removed: [
1912 Removed: tag "section"
1913 Removed: [ class_ "import-warnings" ]
1914 Removed: [
1915 Removed: tag "h2" [] [ txt "Dropped rows" ];
1916 Removed: tag "ul" []
1917 Removed: (List.map
1918 Removed: (fun (w : View_model.Import_warning.t) ->
1919 Removed: tag "li" []
1920 Removed: [
1921 Removed: txt "Row %d: %s" w.View_model.Import_warning.row
1922 Removed: w.View_model.Import_warning.detail;
1923 Removed: ])
1924 Removed: warnings);
1925 Removed: ];
1926 Removed: ]
1927 Removed: in
1928 Removed: (* One row per set. A mapped set shows the catalog name it resolves to. *)
1929 Removed: let set_row (s : View_model.Import_set.t) =
1930 Removed: let mapping =
1931 Removed: match s.View_model.Import_set.mapping with
1932 Removed: | Some name -> Printf.sprintf " → %s" name
1933 Removed: | None -> " → unmapped"
1934 Removed: in
1935 Removed: tag "li" []
1936 Removed: [
1937 Removed: txt "Row %d: %s (%s) %s kg × %s reps%s" s.View_model.Import_set.row
1938 Removed: s.View_model.Import_set.exercise_name s.View_model.Import_set.set_type
1939 Removed: s.View_model.Import_set.weight s.View_model.Import_set.reps mapping;
1940 Removed: ]
1941 Removed: in
1942 Removed: (* One mapping form per distinct source name in the workout. The source name
1943 Removed: is fixed by a hidden field; the trainee supplies the catalog exercise id. *)
1944 Removed: let mapping_form batch_id workout_id source_name =
1945 Removed: tag "form"
1946 Removed: [
1947 Removed: action Routes.import_map batch_id workout_id;
1948 Removed: post_form;
1949 Removed: class_ "import-map-form";
1950 Removed: ]
1951 Removed: [
1952 Removed: Dream_html.csrf_tag request;
1953 Removed: void "input"
1954 Removed: [
1955 Removed: type_ "hidden";
1956 Removed: name "source_name";
1957 Removed: Dream_html.string_attr "value" "%s" source_name;
1958 Removed: ];
1959 Removed: tag "div"
1960 Removed: [ class_ "field" ]
1961 Removed: [
1962 Removed: tag "label" [] [ txt "Map %s to exercise id" source_name ];
1963 Removed: void "input" [ type_ "text"; name "exercise_id"; required ];
1964 Removed: ];
1965 Removed: void "input" [ type_ "submit"; value "Map exercise" ];
1966 Removed: ]
1967 Removed: in
1968 Removed: let confirm_form batch_id workout_id acknowledgement =
1969 Removed: tag "form"
1970 Removed: [
1971 Removed: action Routes.import_confirm batch_id workout_id;
1972 Removed: post_form;
1973 Removed: class_ "import-confirm-form";
1974 Removed: ]
1975 Removed: [
1976 Removed: Dream_html.csrf_tag request;
1977 Removed: tag "div"
1978 Removed: [ class_ "field" ]
1979 Removed: [
1980 Removed: tag "label" [] [ txt "Confirm normal sets" ];
1981 Removed: void "input"
1982 Removed: ([ type_ "text"; name "acknowledgement"; required ]
1983 Removed: @
1984 Removed: match acknowledgement with
1985 Removed: | Some ack -> [ Dream_html.string_attr "value" "%s" ack ]
1986 Removed: | None -> []);
1987 Removed: ];
1988 Removed: void "input" [ type_ "submit"; value "Confirm normal sets" ];
1989 Removed: ]
1990 Removed: in
1991 Removed: let promote_workout_form batch_id workout_id =
1992 Removed: tag "form"
1993 Removed: [
1994 Removed: action Routes.import_promote_workout batch_id workout_id;
1995 Removed: post_form;
1996 Removed: class_ "import-promote-workout-form";
1997 Removed: ]
1998 Removed: [
1999 Removed: Dream_html.csrf_tag request;
2000 Removed: void "input" [ type_ "submit"; value "Promote workout" ];
2001 Removed: ]
2002 Removed: in
2003 Removed: let blockers_block (w : View_model.Import_workout.t) =
2004 Removed: match w.View_model.Import_workout.blockers with
2005 Removed: | [] -> [ tag "p" [ class_ "import-ready" ] [ txt "Ready to promote." ] ]
2006 Removed: | blockers ->
2007 Removed: [
2008 Removed: tag "p" [] [ txt "Blocked:" ];
2009 Removed: tag "ul"
2010 Removed: [ class_ "import-blockers" ]
2011 Removed: (List.map (fun b -> tag "li" [] [ txt "%s" b ]) blockers);
2012 Removed: ]
2013 Removed: in
2014 Removed: let workout_section (w : View_model.Import_workout.t) =
2015 Removed: let batch_id = vm.View_model.Import_review.batch_id in
2016 Removed: let workout_id = w.View_model.Import_workout.id in
2017 Removed: (* Every distinct source name, in first-appearance order. *)
2018 Removed: let source_names =
2019 Removed: List.fold_left
2020 Removed: (fun acc (s : View_model.Import_set.t) ->
2021 Removed: let n = s.View_model.Import_set.exercise_name in
2022 Removed: if List.mem n acc then acc else acc @ [ n ])
2023 Removed: [] w.View_model.Import_workout.sets
2024 Removed: in
2025 Removed: tag "section"
2026 Removed: [ class_ "import-workout" ]
2027 Removed: ([
2028 Removed: tag "h2" [] [ txt "%s" w.View_model.Import_workout.title ];
2029 Removed: tag "ul"
2030 Removed: [ class_ "import-sets" ]
2031 Removed: (List.map set_row w.View_model.Import_workout.sets);
2032 Removed: ]
2033 Removed: @ blockers_block w
2034 Removed: @ List.map (mapping_form batch_id workout_id) source_names
2035 Removed: @ [
2036 Removed: confirm_form batch_id workout_id
2037 Removed: w.View_model.Import_workout.acknowledgement;
2038 Removed: promote_workout_form batch_id workout_id;
2039 Removed: ])
2040 Removed: in
2041 Removed: let batch_promote_form =
2042 Removed: tag "form"
2043 Removed: [
2044 Removed: action Routes.import_promote_batch vm.View_model.Import_review.batch_id;
2045 Removed: post_form;
2046 Removed: class_ "import-promote-batch-form";
2047 Removed: ]
2048 Removed: [
2049 Removed: Dream_html.csrf_tag request;
2050 Removed: void "input" [ type_ "submit"; value "Promote whole batch" ];
2051 Removed: ]
2052 Removed: in
2053 Removed: html_page ~viewer ~request ~active:"profile" "Import review"
2054 Removed: ([ tag "h1" [] [ txt "Import review" ] ]
2055 Removed: @ warnings_block
2056 Removed: @ List.map workout_section vm.View_model.Import_review.workouts
2057 Removed: @ [ batch_promote_form ])
lib/web/pages.mli
index 7cb67e5c..00000000 100644..000000
@@ -1,124 +0,0 @@
1 Removed: type page = Dream_html.node
2 Removed:
3 Removed: val login :
4 Removed: Dream.request -> ?error:string -> ?registration_open:bool -> unit -> page
5 Removed: (** [registration_open] shows the "Register" link. Defaults to [false]. *)
6 Removed:
7 Removed: val register : Dream.request -> ?error:string -> unit -> page
8 Removed:
9 Removed: val choose_routine :
10 Removed: Dream.request ->
11 Removed: logging:bool ->
12 Removed: viewer:View_model.Viewer.t ->
13 Removed: routines:View_model.Routine_choice.t list ->
14 Removed: page
15 Removed:
16 Removed: val home :
17 Removed: Dream.request -> viewer:View_model.Viewer.t -> home:View_model.Home.t -> page
18 Removed:
19 Removed: val workout_in_progress :
20 Removed: Dream.request -> viewer:View_model.Viewer.t -> workout_name:string -> page
21 Removed: (** The Home card shown while a workout is in progress. Links to the current
22 Removed: workout so it stays one tap away. *)
23 Removed:
24 Removed: val routine :
25 Removed: Dream.request ->
26 Removed: ?logging:bool ->
27 Removed: viewer:View_model.Viewer.t ->
28 Removed: View_model.Routine.t ->
29 Removed: page
30 Removed:
31 Removed: val workout :
32 Removed: Dream.request ->
33 Removed: viewer:View_model.Viewer.t ->
34 Removed: ?errors:(string * string) list ->
35 Removed: ?editing:int ->
36 Removed: ?logging:bool ->
37 Removed: record_id:string option ->
38 Removed: active_slot:int ->
39 Removed: View_model.Workout.t ->
40 Removed: page
41 Removed: (** Renders one prescribed slot at a time under a dropdown that selects the
42 Removed: exercise. Authenticated pages carry generic progressive-enhancement markers.
43 Removed: Current workouts additionally mark slot links and forms, while saved-record
44 Removed: edits use the generic authenticated form path. [active_slot] is the slot the
45 Removed: view opens on. A handler clamps a requested slot and falls back to the first
46 Removed: outstanding slot. [record_id] is [None] for the workout in progress and
47 Removed: [Some id] for a saved history record. [logging] sets the middle navigation
48 Removed: action. It defaults to a live workout view (true when [record_id] is
49 Removed: [None]). *)
50 Removed:
51 Removed: type feedback_flow = { step : int; answers : (string * string) list }
52 Removed:
53 Removed: val feedback_factors : (string * string) list
54 Removed: (** Ordered factor codes and labels used by the sequential feedback flow. *)
55 Removed:
56 Removed: val logbook :
57 Removed: Dream.request ->
58 Removed: ?logging:bool ->
59 Removed: viewer:View_model.Viewer.t ->
60 Removed: ?feedback:View_model.Feedback.report list ->
61 Removed: ?suggest_feedback:bool ->
62 Removed: ?feedback_flow:feedback_flow ->
63 Removed: ?feedback_open:bool ->
64 Removed: View_model.Logbook_entry.t list ->
65 Removed: page
66 Removed: (** The logbook: recorded workouts and a modal sequential subjective feedback
67 Removed: flow. [feedback] lists past reports, most recent first. [suggest_feedback]
68 Removed: shows a prompt inviting feedback, set after a workout finishes. *)
69 Removed:
70 Removed: val profile :
71 Removed: Dream.request ->
72 Removed: ?logging:bool ->
73 Removed: viewer:View_model.Viewer.t ->
74 Removed: ?username_error:string ->
75 Removed: ?password_error:string ->
76 Removed: ?notice:string ->
77 Removed: unit ->
78 Removed: page
79 Removed: (** The account profile page: rename the account and change the password, each
80 Removed: an independent form. [username_error] and [password_error] report a refused
81 Removed: change inline. [notice] confirms a successful one. *)
82 Removed:
83 Removed: val settings :
84 Removed: Dream.request ->
85 Removed: ?logging:bool ->
86 Removed: viewer:View_model.Viewer.t ->
87 Removed: theme:string ->
88 Removed: unit ->
89 Removed: page
90 Removed: (** The session-scoped theme settings page. *)
91 Removed:
92 Removed: val import_review :
93 Removed: Dream.request ->
94 Removed: viewer:View_model.Viewer.t ->
95 Removed: View_model.Import_review.t ->
96 Removed: page
97 Removed: (** The review of a provisional Hevy import batch. Lists the rows dropped on
98 Removed: parse, then each workout with its sets, blockers, and acknowledgement state.
99 Removed: Renders a mapping form per distinct source name, a normal-set confirmation
100 Removed: form, a per-workout promote form, and a batch promote form. Basic accessible
101 Removed: HTML: it names no core entity, only the view model. *)
102 Removed:
103 Removed: val problem : title:string -> detail:string -> page
104 Removed:
105 Removed: val error_page :
106 Removed: ?viewer:View_model.Viewer.t ->
107 Removed: ?request:Dream.request ->
108 Removed: status:int ->
109 Removed: unit ->
110 Removed: page
111 Removed: (** A branded page for an HTTP failure. It names only the class of failure, so
112 Removed: no server-supplied string leaks, and keeps the navigation affordances: top
113 Removed: on desktop, bottom on mobile. *)
114 Removed:
115 Removed: val app_feedback :
116 Removed: Dream.request ->
117 Removed: ?logging:bool ->
118 Removed: viewer:View_model.Viewer.t ->
119 Removed: ?tab:[ `Write | `Submitted ] ->
120 Removed: ?error:string ->
121 Removed: View_model.App_feedback.t list ->
122 Removed: page
123 Removed: (** The authenticated application feedback page. One tab accepts a freeform
124 Removed: message. The other lists the trainee's submitted messages. *)
lib/web/routes.ml
index 79485b22..00000000 100644..000000
@@ -1,39 +0,0 @@
1 Removed: (* The path values carry format types that are inferred and checked at each use.
2 Removed: Writing an interface for them would add noise without adding safety. *)
3 Removed:
4 Removed: let%path home = "/"
5 Removed: let%path register = "/register"
6 Removed: let%path login = "/login"
7 Removed: let%path logout = "/logout"
8 Removed: let%path routines = "/routines"
9 Removed: let%path select_routine = "/routines/%s/select"
10 Removed: let%path routine = "/routine"
11 Removed: let%path workout = "/workout"
12 Removed: let%path workout_slot = "/workout/slots/%d"
13 Removed: let%path workout_slot_edit = "/workout/slots/%d/edit"
14 Removed: let%path finish_workout = "/workout/finish"
15 Removed: let%path cancel_workout = "/workout/cancel"
16 Removed: let%path logbook = "/logbook"
17 Removed: let%path profile = "/profile"
18 Removed: let%path settings = "/settings"
19 Removed: let%path settings_theme = "/settings/theme"
20 Removed: let%path profile_username = "/profile/username"
21 Removed: let%path profile_password = "/profile/password"
22 Removed: let%path import_upload = "/profile/import"
23 Removed: let%path import_review = "/profile/import/%s"
24 Removed: let%path import_map = "/profile/import/%s/workouts/%s/map"
25 Removed: let%path import_confirm = "/profile/import/%s/workouts/%s/confirm"
26 Removed: let%path import_promote_workout = "/profile/import/%s/workouts/%s/promote"
27 Removed: let%path import_promote_batch = "/profile/import/%s/promote"
28 Removed: let%path app_feedback = "/app-feedback"
29 Removed: let%path submit_app_feedback = "/app-feedback/submit"
30 Removed: let%path upvote_app_feedback = "/app-feedback/%s/upvote"
31 Removed: let%path edit_app_feedback = "/app-feedback/%s/edit"
32 Removed: let%path remove_app_feedback = "/app-feedback/%s/remove"
33 Removed: let%path feedback = "/feedback"
34 Removed: let%path cancel_feedback = "/feedback/cancel"
35 Removed: let%path record = "/logbook/%s"
36 Removed: let%path record_slot = "/logbook/%s/slots/%d"
37 Removed: let%path record_slot_edit = "/logbook/%s/slots/%d/edit"
38 Removed: let%path stylesheet = "/assets/hito.css"
39 Removed: let%path workout_client = "/assets/workout-client.js"
lib/web/view_model.ml
index afe53fa5..00000000 100644..000000
@@ -1,183 +0,0 @@
1 Removed: (** View models: the simple data structures the presenter renders.
2 Removed:
3 Removed: The Clean Architecture rule for crossing a boundary is that the data passed
4 Removed: is a plain structure, never an entity or a database row. These DTOs are that
5 Removed: structure. A handler (the controller) maps a repository record or a core
6 Removed: entity into a view model, and the presenter ({!Pages}) renders the view
7 Removed: model without ever naming an inner-circle type.
8 Removed:
9 Removed: Every page now crosses the boundary as a DTO. No page names a core entity.
10 Removed: Add a page DTO here before the presenter renders it. *)
11 Removed:
12 Removed: module Viewer = struct
13 Removed: type t = { username : string }
14 Removed: (** The signed-in trainee, as the shell needs them: a name to show, nothing
15 Removed: more. Replaces passing the [Trainee.t] entity into the presenter. *)
16 Removed: end
17 Removed:
18 Removed: module App_feedback = struct
19 Removed: type t = {
20 Removed: id : string; (** Opaque feedback identity, already stringified. *)
21 Removed: author : string;
22 Removed: contributions : int;
23 Removed: submitted_at : string; (** Pre-formatted "YYYY-MM-DD HH:MM" in UTC. *)
24 Removed: message : string;
25 Removed: upvotes : int;
26 Removed: viewer_upvoted : bool;
27 Removed: viewer_owns : bool;
28 Removed: }
29 Removed: (** One application-feedback entry, ready to render. Carries no timestamp
30 Removed: type, no opaque id, no repository record — only what the view shows. *)
31 Removed: end
32 Removed:
33 Removed: module Feedback = struct
34 Removed: type report = {
35 Removed: at_unix : int; (** Report time in Unix seconds, for chronological order. *)
36 Removed: signals : string list; (** Each signal already described, for the list. *)
37 Removed: factor_scores : (string * int option) list;
38 Removed: (** Score per leveled factor field, aligned with the factor order the
39 Removed: presenter graphs. [None] where the report gave that factor no level.
40 Removed: Flags such as pain carry no score and are absent here. *)
41 Removed: }
42 Removed: (** One subjective-feedback report, ready to render. Carries no
43 Removed: [Evidence.Feedback] variant and no timestamp type. *)
44 Removed: end
45 Removed:
46 Removed: module Routine = struct
47 Removed: type exercise = { name : string; reps : string }
48 Removed: (** One prescribed exercise line, its name and rep band already phrased. *)
49 Removed:
50 Removed: type workout = { name : string; exercises : exercise list }
51 Removed:
52 Removed: type t = { name : string; workouts : workout list }
53 Removed: (** A routine as the accordion descriptions render it: names, numeric rep
54 Removed: bands, and workout grouping only. No [Prescription] entity. *)
55 Removed: end
56 Removed:
57 Removed: module Routine_choice = struct
58 Removed: type t = { id : string; name : string; workout_count : int }
59 Removed: (** One selectable routine on the chooser: its id for the select form, its
60 Removed: name, and how many workouts the cycle holds. *)
61 Removed: end
62 Removed:
63 Removed: module Home = struct
64 Removed: type gate =
65 Removed: | Ready
66 Removed: | Recovering of { status : string }
67 Removed: (** Whether the next workout may begin, and the recovery status line to
68 Removed: show while it may not. *)
69 Removed:
70 Removed: type t = {
71 Removed: routine_id : string; (** For the begin/override forms. *)
72 Removed: routine_name : string;
73 Removed: next_workout : string;
74 Removed: gate : gate;
75 Removed: }
76 Removed: (** The overview while no workout is in progress. The recovery policy has
77 Removed: already decided [gate] in the use case. The page only renders it. *)
78 Removed: end
79 Removed:
80 Removed: module Workout = struct
81 Removed: type movement = {
82 Removed: name : string; (** The exercise name shown for this input row. *)
83 Removed: load_field : string; (** Form field name for the load input. *)
84 Removed: reps_field : string; (** Form field name for the reps input. *)
85 Removed: reps_label : string;
86 Removed: load_value : string option;
87 Removed: (** Pre-fill when correcting a recorded slot. *)
88 Removed: reps_value : string option;
89 Removed: }
90 Removed: (** One input row of a slot's record/correction form. The controller emits one
91 Removed: movement for a single-exercise slot and two for a pre-exhaust pair, so the
92 Removed: presenter renders whatever movements it is given and never inspects the
93 Removed: prescription's [delivery] shape itself. *)
94 Removed:
95 Removed: type slot = {
96 Removed: index : int;
97 Removed: label : string; (** Exercise label for the selector option and heading. *)
98 Removed: movements : movement list;
99 Removed: recorded_summary : string option;
100 Removed: (** [Some description] once the slot is filled. [None] while
101 Removed: outstanding. Drives the recorded/outstanding branch without an
102 Removed: entity. *)
103 Removed: selected_extension : string;
104 Removed: (** The ending code to pre-select when correcting a recorded slot ("",
105 Removed: "forced", "negatives", "rest-pause", "static"). "" for an
106 Removed: outstanding slot. *)
107 Removed: }
108 Removed:
109 Removed: type t = {
110 Removed: name : string; (** The workout (prescription) name, for the page title. *)
111 Removed: slots : slot list;
112 Removed: active_slot : int;
113 Removed: started_at_unix : int; (** Workout start, for the elapsed timer. *)
114 Removed: overridden : bool; (** Begun before recovery finished. *)
115 Removed: all_recorded : bool; (** Every prescribed slot has a record. *)
116 Removed: }
117 Removed: (** A workout view: the slot the trainee is on, the record/correction state of
118 Removed: each slot, and the timer and override facts. Carries no Evidence or
119 Removed: Prescription entity. Extension/ending selection stays in the presenter
120 Removed: form, since its option set is fixed UI vocabulary, not domain data. *)
121 Removed: end
122 Removed:
123 Removed: module Logbook_entry = struct
124 Removed: type t = {
125 Removed: id : string; (** Record id for the link. *)
126 Removed: workout_name : string;
127 Removed: stimuli_count : int;
128 Removed: complete : bool;
129 Removed: }
130 Removed: (** One saved workout as the logbook list shows it: a link, a name, how many
131 Removed: stimuli it holds, and whether it is complete. No Repository record and no
132 Removed: Evidence.Workout entity. *)
133 Removed: end
134 Removed:
135 Removed: module Import_warning = struct
136 Removed: type t = {
137 Removed: row : int; (** The source row that the adapter dropped. *)
138 Removed: detail : string; (** Why the row was dropped, already phrased. *)
139 Removed: }
140 Removed: (** One dropped import row, ready to render. The controller phrases the
141 Removed: reason, so no adapter warning variant crosses the boundary. *)
142 Removed: end
143 Removed:
144 Removed: module Import_set = struct
145 Removed: type t = {
146 Removed: row : int; (** The source row, for provenance. *)
147 Removed: exercise_name : string; (** The export's exercise name, before mapping. *)
148 Removed: set_type : string; (** The set type, already phrased. *)
149 Removed: weight : string; (** The load, already formatted. *)
150 Removed: reps : string; (** The rep count, already formatted. *)
151 Removed: mapping : string option;
152 Removed: (** The catalog exercise name this set maps to, when confirmed. *)
153 Removed: }
154 Removed: (** One provisional import set, ready to render. Carries only strings, no
155 Removed: source-set entity. *)
156 Removed: end
157 Removed:
158 Removed: module Import_workout = struct
159 Removed: type t = {
160 Removed: id : string; (** The workout id, for the per-workout forms. *)
161 Removed: title : string;
162 Removed: sets : Import_set.t list;
163 Removed: blockers : string list;
164 Removed: (** Why the workout cannot promote yet, each already phrased. Empty when
165 Removed: the workout can promote. *)
166 Removed: acknowledgement : string option;
167 Removed: (** The retained normal-set acknowledgement, when present. *)
168 Removed: revision : int; (** The workout revision, for provenance display. *)
169 Removed: }
170 Removed: (** One provisional import workout, ready to render: its sets, its blockers,
171 Removed: and its acknowledgement state. No Workout_import entity. *)
172 Removed: end
173 Removed:
174 Removed: module Import_review = struct
175 Removed: type t = {
176 Removed: batch_id : string;
177 Removed: warnings : Import_warning.t list;
178 Removed: workouts : Import_workout.t list;
179 Removed: }
180 Removed: (** A provisional import batch under review: the rows dropped on parse and the
181 Removed: workouts awaiting cleaning and promotion. No batch entity crosses the
182 Removed: boundary. *)
183 Removed: end