feat Improve workout controls and navigation

Add cancellation controls, responsive menu navigation, and consistent authenticated page affordances.

Commit
62b7d9e866f89f2ffb79a2e9790b5c2315897b96
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/web/assets/hito.css
index 21bf650e..b8cff9b3 100644..100644
@@ -143,6 +143,93 @@
143 143 font-weight: 800;
144 144 }
145 145
146 Added: /* The compact-desktop / mobile hamburger menu. It is a native details/summary
147 Added: disclosure carrying the same primary links and a CSRF sign-out form. It is
148 Added: hidden on wide desktop, where the inline .primary-nav and .account sign-out
149 Added: are shown instead. A responsive rule below reveals it once those crowd. */
150 Added: .menu { position: relative; display: none; }
151 Added:
152 Added: .menu-toggle {
153 Added: display: inline-flex;
154 Added: align-items: center;
155 Added: justify-content: center;
156 Added: width: 2.75rem;
157 Added: height: 2.75rem;
158 Added: border: 1px solid var(--rule-strong);
159 Added: border-radius: 0.3rem;
160 Added: background: var(--paper);
161 Added: cursor: pointer;
162 Added: list-style: none;
163 Added: }
164 Added:
165 Added: /* Suppress the native disclosure triangle across engines. */
166 Added: .menu-toggle::-webkit-details-marker { display: none; }
167 Added: .menu-toggle::marker { content: ""; }
168 Added:
169 Added: .menu-toggle:hover { background: #ece4d5; }
170 Added:
171 Added: .menu-toggle-bars,
172 Added: .menu-toggle-bars::before,
173 Added: .menu-toggle-bars::after {
174 Added: display: block;
175 Added: width: 1.25rem;
176 Added: height: 2px;
177 Added: background: var(--ink);
178 Added: }
179 Added:
180 Added: .menu-toggle-bars { position: relative; }
181 Added: .menu-toggle-bars::before,
182 Added: .menu-toggle-bars::after {
183 Added: position: absolute;
184 Added: left: 0;
185 Added: content: "";
186 Added: }
187 Added: .menu-toggle-bars::before { top: -0.4rem; }
188 Added: .menu-toggle-bars::after { top: 0.4rem; }
189 Added:
190 Added: .menu-panel {
191 Added: position: absolute;
192 Added: z-index: 30;
193 Added: top: calc(100% + 0.4rem);
194 Added: right: 0;
195 Added: display: flex;
196 Added: flex-direction: column;
197 Added: min-width: 12rem;
198 Added: border: 1px solid var(--rule-strong);
199 Added: border-top: 3px solid var(--oxblood);
200 Added: background: var(--paper-raised);
201 Added: border-radius: 0.35rem;
202 Added: box-shadow: 0 0.4rem 1.5rem rgba(26, 26, 22, 0.15);
203 Added: padding: 0.4rem;
204 Added: }
205 Added:
206 Added: .menu-panel > a {
207 Added: display: flex;
208 Added: align-items: center;
209 Added: min-height: 2.75rem;
210 Added: padding: 0.55rem 0.7rem;
211 Added: color: var(--ink);
212 Added: border-radius: 0.25rem;
213 Added: font-size: 0.95rem;
214 Added: text-decoration: none;
215 Added: }
216 Added:
217 Added: .menu-panel > a:hover {
218 Added: background: #ece4d5;
219 Added: color: var(--oxblood-dark);
220 Added: }
221 Added:
222 Added: .menu-panel .logout { margin: 0.3rem 0 0; }
223 Added: .menu-panel .logout input[type="submit"] { margin-top: 0; }
224 Added:
225 Added: .page-home .menu-panel > a[href="/"],
226 Added: .page-routine .menu-panel > a[href="/routine"],
227 Added: .page-workout .menu-panel > a[href="/workout"],
228 Added: .page-history .menu-panel > a[href="/history"] {
229 Added: color: var(--paper-raised);
230 Added: background: var(--oxblood);
231 Added: }
232 Added:
146 233 .page-home .bottom-nav > a[href="/"],
147 234 .page-routine .bottom-nav > a[href="/routine"],
148 235 .page-workout .bottom-nav > a[href="/workout"],
@@ -165,6 +252,7 @@
165 252 padding: clamp(1.25rem, 3vw, 2rem);
166 253 }
167 254
255 Added: h1,
168 256 h2 {
169 257 max-width: 38rem;
170 258 margin: 0 0 0.45rem;
@@ -261,6 +349,28 @@
261 349 cursor: not-allowed;
262 350 }
263 351
352 Added: /* A secondary submit: a lower-emphasis action next to the primary one. It reads
353 Added: as a receding, outlined control so the primary submit stays the obvious
354 Added: choice. Used for "Cancel logging" beside "Finish workout". */
355 Added: input[type="submit"].secondary {
356 Added: color: var(--oxblood-dark);
357 Added: background: transparent;
358 Added: border-color: var(--rule-strong);
359 Added: }
360 Added: input[type="submit"].secondary:hover {
361 Added: color: var(--paper-raised);
362 Added: background: var(--rule-strong);
363 Added: }
364 Added:
365 Added: /* Keeps the finish and cancel controls together as equal-width actions. */
366 Added: .button-group {
367 Added: display: grid;
368 Added: grid-template-columns: repeat(2, minmax(0, 1fr));
369 Added: gap: 0.8rem;
370 Added: }
371 Added: .button-group form { margin: 0; }
372 Added: .button-group input[type="submit"] { width: 100%; }
373 Added:
264 374 .row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.8rem; }
265 375
266 376 /* The exercise button group. Server-rendered links, one per prescription slot,
@@ -348,27 +458,35 @@
348 458 letter-spacing: 0.02em;
349 459 }
350 460
351 Removed: ul { margin: 1rem 0; padding: 0; list-style: none; }
461 Added: ul {
462 Added: margin: 1rem 0;
463 Added: padding-left: 1.5rem;
464 Added: list-style: disc;
465 Added: }
352 466 li {
353 Removed: position: relative;
354 Removed: margin: 0;
355 Removed: padding: 0.8rem 0.85rem 0.8rem 1.25rem;
356 Removed: border-top: 1px solid var(--rule);
467 Added: margin: 0.4rem 0;
468 Added: padding: 0;
357 469 color: var(--ink);
358 Removed: background: var(--paper-raised);
359 470 }
360 Removed: li::before {
361 Removed: position: absolute;
362 Removed: top: 1.2rem;
363 Removed: left: 0.55rem;
364 Removed: width: 0.35rem;
365 Removed: height: 0.35rem;
366 Removed: background: var(--evergreen);
367 Removed: content: "";
471 Added: li::marker { color: #000; }
472 Added: li ul {
473 Added: margin: 0.55rem 0 0;
474 Added: list-style: disc;
368 475 }
369 Removed: li ul { margin: 0.55rem 0 0; }
370 Removed: li li { border-top-color: #ded8cc; color: var(--muted-ink); font-size: 0.92rem; }
476 Added: li li { color: var(--muted-ink); font-size: 0.92rem; }
371 477
478 Added: /* Compact desktop: from just above the mobile breakpoint up to 60rem (960px).
479 Added: Four inline text links plus the brand and a sign-out button start to crowd
480 Added: and wrap here, so the inline .primary-nav and header sign-out give way to the
481 Added: hamburger .menu. The bottom nav stays a mobile-only affordance. 60rem is the
482 Added: chosen compact-desktop breakpoint: comfortably wider than the 42rem mobile
483 Added: breakpoint, and about where the inline row runs out of room. */
484 Added: @media (min-width: 42.0625rem) and (max-width: 60rem) {
485 Added: .primary-nav { display: none; }
486 Added: .account { display: none; }
487 Added: .menu { display: block; }
488 Added: }
489 Added:
372 490 @media (max-width: 42rem) {
373 491 .app-shell {
374 492 padding: 0 0.75rem calc(6.75rem + env(safe-area-inset-bottom));
@@ -381,6 +499,12 @@
381 499
382 500 .primary-nav { display: none; }
383 501
502 Added: /* The inline header sign-out is hidden with the primary nav; the hamburger
503 Added: .menu is retained so sign-out stays reachable. The bottom nav carries the
504 Added: three main destinations but has no sign-out. */
505 Added: .account { display: none; }
506 Added: .menu { display: block; }
507 Added:
384 508 .bottom-nav {
385 509 position: fixed;
386 510 z-index: 20;
@@ -388,7 +512,7 @@
388 512 bottom: 0;
389 513 left: 0;
390 514 display: grid;
391 Removed: grid-template-columns: repeat(3, minmax(0, 1fr));
515 Added: grid-template-columns: repeat(4, minmax(0, 1fr));
392 516 gap: 1px;
393 517 border-top: 2px solid var(--ink);
394 518 background: var(--ink);
@@ -417,7 +541,8 @@
417 541 }
418 542
419 543 @media (max-width: 26rem) {
420 Removed: .row { grid-template-columns: 1fr; }
544 Added: .row,
545 Added: .button-group { grid-template-columns: 1fr; }
421 546 }
422 547
423 548 @media (prefers-reduced-motion: reduce) {
lib/web/handlers.ml
index c69ad5aa..17ac9257 100644..100644
@@ -251,7 +251,7 @@
251 251
252 252 let routine t trainee request =
253 253 Service.active_routine t.service trainee.Trainee.id >>= function
254 Removed: | Some (_, routine) -> html (Pages.routine ~trainee routine)
254 Added: | Some (_, routine) -> html (Pages.routine request ~trainee routine)
255 255 | None -> redirect_to request Routes.routines
256 256
257 257 let routes t =
@@ -344,6 +344,17 @@
344 344 | Some _ -> redirect_to request Routes.history
345 345 | None -> not_found Present.no_workout)
346 346
347 Added: (* Discard the workout in progress and return home. CSRF-guarded like
348 Added: finish. The service clears only the in-progress slot, so saved history is
349 Added: untouched. *)
350 Added: let cancel t trainee request =
351 Added: guard_csrf request >>= function
352 Added: | Error _ -> bad_request Present.form_invalid
353 Added: | Ok () -> (
354 Added: Service.cancel t.service trainee.Trainee.id >>= function
355 Added: | true -> redirect_to request Routes.home
356 Added: | false -> not_found Present.no_workout)
357 Added:
347 358 let routes t =
348 359 [
349 360 Dream_html.get Routes.workout (fun request ->
@@ -357,13 +368,15 @@
357 368 authenticated t request (fun trainee -> edit t trainee request slot));
358 369 Dream_html.post Routes.finish_workout (fun request ->
359 370 authenticated t request (fun trainee -> finish t trainee request));
371 Added: Dream_html.post Routes.cancel_workout (fun request ->
372 Added: authenticated t request (fun trainee -> cancel t trainee request));
360 373 ]
361 374 end
362 375
363 376 module History = struct
364 Removed: let index t trainee _request =
377 Added: let index t trainee request =
365 378 Service.history t.service trainee.Trainee.id >>= fun records ->
366 Removed: html (Pages.history ~trainee records)
379 Added: html (Pages.history request ~trainee records)
367 380
368 381 let show t trainee request id =
369 382 record_target t trainee.Trainee.id id >>= function
lib/web/pages.ml
index 803d17e6..d779aac8 100644..100644
@@ -17,20 +17,25 @@
17 17 let post_form = Dream_html.string_attr "method" "post"
18 18
19 19 (* The shell. [trainee] and [request] are present on authenticated pages, which
20 Removed: then show who is signed in and a logout control. Auth pages omit both. *)
21 Removed: let html_page ?trainee ?request ?(active = "") ?(workout_in_progress = false)
22 Removed: title content =
20 Added: then show a logout control and the username on the history link. Auth pages
21 Added: omit both. *)
22 Added: let html_page ?trainee ?request ?(active = "") title content =
23 23 let nav_link path label = tag "a" [ href path ] [ txt "%s" label ] in
24 Added: let navigation_links username =
25 Added: [
26 Added: nav_link Routes.home "Overview";
27 Added: nav_link Routes.routine "Routine";
28 Added: nav_link Routes.workout "Current workout";
29 Added: nav_link Routes.history username;
30 Added: ]
31 Added: in
24 32 let account_area =
25 33 match (trainee, request) with
26 Removed: | Some (trainee : Trainee.t), Some request ->
34 Added: | Some _, Some request ->
27 35 [
28 36 tag "div"
29 37 [ class_ "account" ]
30 38 [
31 Removed: tag "span"
32 Removed: [ class_ "account-username" ]
33 Removed: [ txt "%s" (Trainee.username_to_string trainee.username) ];
34 39 tag "form"
35 40 [ action Routes.logout; post_form; class_ "logout" ]
36 41 [
@@ -43,37 +48,62 @@
43 48 in
44 49 let primary_nav =
45 50 match (trainee, request) with
46 Removed: | Some _, Some _ ->
51 Added: | Some (trainee : Trainee.t), Some _ ->
47 52 [
48 53 tag "nav"
49 54 [ class_ "primary-nav" ]
55 Added: (navigation_links (Trainee.username_to_string trainee.username));
56 Added: ]
57 Added: | _ -> []
58 Added: in
59 Added: (* The compact-desktop and mobile fallback: a native details/summary
60 Added: disclosure. It carries the same primary links as [primary_nav] plus a
61 Added: CSRF-protected sign-out form, so sign-out stays reachable at any width
62 Added: where the inline [account_area] is hidden. [details]/[summary] is a
63 Added: built-in, keyboard-operable, screen-reader-announced disclosure — no
64 Added: client script and no ARIA to hand-maintain. CSS shows it only when the
65 Added: inline navigation would crowd or is hidden. *)
66 Added: let menu =
67 Added: match (trainee, request) with
68 Added: | Some (trainee : Trainee.t), Some request ->
69 Added: [
70 Added: tag "details"
71 Added: [ class_ "menu" ]
50 72 [
51 Removed: nav_link Routes.home "Overview";
52 Removed: nav_link Routes.routine "Routine";
53 Removed: nav_link Routes.workout "Current workout";
54 Removed: nav_link Routes.history "History";
73 Added: tag "summary"
74 Added: [
75 Added: class_ "menu-toggle";
76 Added: Dream_html.string_attr "aria-label" "Menu";
77 Added: ]
78 Added: [ tag "span" [ class_ "menu-toggle-bars" ] [] ];
79 Added: tag "nav"
80 Added: [
81 Added: class_ "menu-panel";
82 Added: Dream_html.string_attr "aria-label" "Menu navigation";
83 Added: ]
84 Added: (navigation_links (Trainee.username_to_string trainee.username)
85 Added: @ [
86 Added: tag "form"
87 Added: [ action Routes.logout; post_form; class_ "logout" ]
88 Added: [
89 Added: Dream_html.csrf_tag request;
90 Added: void "input" [ type_ "submit"; value "Sign out" ];
91 Added: ];
92 Added: ]);
55 93 ];
56 94 ]
57 95 | _ -> []
58 96 in
59 Removed: let mobile_middle =
60 Removed: if workout_in_progress then nav_link Routes.workout "Current Workout"
61 Removed: else nav_link Routes.routine "Routine"
62 Removed: in
63 97 let bottom_nav =
64 98 match (trainee, request) with
65 Removed: | Some _, Some _ ->
99 Added: | Some (trainee : Trainee.t), Some _ ->
66 100 [
67 101 tag "nav"
68 102 [
69 103 class_ "bottom-nav";
70 104 Dream_html.string_attr "aria-label" "Mobile navigation";
71 105 ]
72 Removed: [
73 Removed: nav_link Routes.home "Home";
74 Removed: mobile_middle;
75 Removed: nav_link Routes.history "Log Book";
76 Removed: ];
106 Added: (navigation_links (Trainee.username_to_string trainee.username));
77 107 ]
78 108 | _ -> []
79 109 in
@@ -113,7 +143,7 @@
113 143 ];
114 144 ];
115 145 ]
116 Removed: @ primary_nav @ account_area);
146 Added: @ primary_nav @ account_area @ menu);
117 147 tag "main" [] [ tag "div" [ class_ "page-surface" ] content ];
118 148 ];
119 149 ]
@@ -321,8 +351,8 @@
321 351 ]
322 352 @ gate)
323 353
324 Removed: let routine ~trainee routine =
325 Removed: html_page ~trainee ~active:"routine" "Routine"
354 Added: let routine request ~trainee routine =
355 Added: html_page ~trainee ~request ~active:"routine" "Routine"
326 356 [
327 357 tag "h2" [] [ txt "%s" (Prescription.Routine.name routine) ];
328 358 tag "div"
@@ -679,23 +709,33 @@
679 709 | Some _ -> []
680 710 | None ->
681 711 [
682 Removed: tag "form"
683 Removed: [ action Routes.finish_workout; post_form ]
712 Added: tag "div"
713 Added: [ class_ "button-group" ]
684 714 [
685 Removed: Dream_html.csrf_tag request;
686 Removed: void "input" [ type_ "submit"; value "Finish workout" ];
715 Added: tag "form"
716 Added: [ action Routes.finish_workout; post_form ]
717 Added: [
718 Added: Dream_html.csrf_tag request;
719 Added: void "input" [ type_ "submit"; value "Finish workout" ];
720 Added: ];
721 Added: tag "form"
722 Added: [ action Routes.cancel_workout; post_form ]
723 Added: [
724 Added: Dream_html.csrf_tag request;
725 Added: void "input"
726 Added: [
727 Added: type_ "submit"; class_ "secondary"; value "Cancel logging";
728 Added: ];
729 Added: ];
687 730 ];
688 731 ]
689 732 in
690 733 html_page ~trainee ~request ~active:"workout"
691 Removed: ~workout_in_progress:(Option.is_none record_id)
692 734 (Prescription.Workout.name prescription)
693 735 (override_note
694 736 @ [
737 Added: tag "h1" [] [ txt "%s" (Prescription.Workout.name prescription) ];
695 738 tag "p"
696 Removed: [ class_ "eyebrow" ]
697 Removed: [ txt "%s" (Prescription.Workout.name prescription) ];
698 Removed: tag "p"
699 739 [ class_ "ledger-meta" ]
700 740 [
701 741 txt "%d of %d recorded."
@@ -714,8 +754,8 @@
714 754 | (slot, _) :: _ -> slot
715 755 | [] -> 0
716 756
717 Removed: let history ~trainee records =
718 Removed: html_page ~trainee ~active:"history" "History"
757 Added: let history request ~trainee records =
758 Added: html_page ~trainee ~request ~active:"history" "History"
719 759 [
720 760 tag "h2" [] [ txt "History" ];
721 761 (if records = [] then tag "p" [] [ txt "Nothing logged yet." ]
lib/web/pages.mli
index 72a3e0a0..69764c77 100644..100644
@@ -20,7 +20,8 @@
20 20 readiness:Recovery.readiness ->
21 21 page
22 22
23 Removed: val routine : trainee:Trainee.t -> Prescription.Routine.t -> page
23 Added: val routine :
24 Added: Dream.request -> trainee:Trainee.t -> Prescription.Routine.t -> page
24 25
25 26 val workout :
26 27 Dream.request ->
@@ -40,5 +41,7 @@
40 41 (** The slot a workout view opens on: the first slot still awaiting a record, or
41 42 the first slot when every slot is filled. *)
42 43
43 Removed: val history : trainee:Trainee.t -> Repository.record list -> page
44 Added: val history :
45 Added: Dream.request -> trainee:Trainee.t -> Repository.record list -> page
46 Added:
44 47 val problem : title:string -> detail:string -> page
lib/web/routes.ml
index 318cd235..3be8e606 100644..100644
@@ -12,6 +12,7 @@
12 12 let%path workout_slot = "/workout/slots/%d"
13 13 let%path workout_slot_edit = "/workout/slots/%d/edit"
14 14 let%path finish_workout = "/workout/finish"
15 Added: let%path cancel_workout = "/workout/cancel"
15 16 let%path history = "/history"
16 17 let%path record = "/history/%s"
17 18 let%path record_slot = "/history/%s/slots/%d"
test/test_web.ml
index 9afd9a80..92d224be 100644..100644
@@ -153,14 +153,92 @@
153 153 "shows the routine catalog" true
154 154 (contains ~substring:"Routines" (body overview));
155 155 Alcotest.(check bool)
156 Removed: "shows the signed-in username" true
157 Removed: (contains ~substring:"lifter" (body overview));
156 Added: "shows the username in history navigation" true
157 Added: (contains ~substring:"href=\"/history\">lifter</a>"
158 Added: (body overview));
158 159 Alcotest.(check bool)
160 Added: "omits the header username" false
161 Added: (contains ~substring:"account-username" (body overview));
162 Added: Alcotest.(check bool)
159 163 "shows primary navigation" true
160 164 (contains ~substring:"primary-nav" (body overview));
161 165 Alcotest.(check bool)
162 166 "shows bottom navigation" true
163 167 (contains ~substring:"bottom-nav" (body overview)) );
168 Added: ( "authenticated pages carry both inline and hamburger affordances",
169 Added: `Quick,
170 Added: fun () ->
171 Added: let c = client () in
172 Added: let _ = sign_in_new c in
173 Added: let page = body (get c "/") in
174 Added: (* Inline affordances: the wide-desktop primary nav and the header
175 Added: sign-out form both exist in the markup, ready for responsive CSS
176 Added: to reveal or hide them. *)
177 Added: Alcotest.(check bool)
178 Added: "keeps the inline primary navigation" true
179 Added: (contains ~substring:"class=\"primary-nav\"" page);
180 Added: Alcotest.(check bool)
181 Added: "keeps the inline header sign-out" true
182 Added: (contains ~substring:"class=\"account\"" page);
183 Added: (* The hamburger menu: a native details/summary disclosure with its
184 Added: toggle and panel classes, present at every width so responsive
185 Added: CSS decides when to show it. *)
186 Added: Alcotest.(check bool)
187 Added: "renders the details/summary menu" true
188 Added: (contains ~substring:"class=\"menu\"" page);
189 Added: Alcotest.(check bool)
190 Added: "renders the menu toggle summary" true
191 Added: (contains ~substring:"class=\"menu-toggle\"" page);
192 Added: Alcotest.(check bool)
193 Added: "renders the menu panel" true
194 Added: (contains ~substring:"class=\"menu-panel\"" page);
195 Added: Alcotest.(check bool)
196 Added: "uses a native disclosure element" true
197 Added: (contains ~substring:"<details" page) );
198 Added: ( "the hamburger menu holds the primary links and a CSRF sign-out",
199 Added: `Quick,
200 Added: fun () ->
201 Added: let c = client () in
202 Added: let _ = sign_in_new c in
203 Added: let page = body (get c "/") in
204 Added: (* Isolate the menu panel so the assertions do not pass on the
205 Added: inline nav's copies of the same links. *)
206 Added: let panel =
207 Added: let start =
208 Added: Option.get (index_from ~needle:"class=\"menu-panel\"" page 0)
209 Added: in
210 Added: let stop = Option.get (index_from ~needle:"</nav>" page start) in
211 Added: String.sub page start (stop - start)
212 Added: in
213 Added: Alcotest.(check bool)
214 Added: "menu links to the overview" true
215 Added: (contains ~substring:"href=\"/\"" panel);
216 Added: Alcotest.(check bool)
217 Added: "menu links to the routine" true
218 Added: (contains ~substring:"href=\"/routine\"" panel);
219 Added: Alcotest.(check bool)
220 Added: "menu links to the current workout" true
221 Added: (contains ~substring:"href=\"/workout\"" panel);
222 Added: Alcotest.(check bool)
223 Added: "menu links to the history" true
224 Added: (contains ~substring:"href=\"/history\"" panel);
225 Added: Alcotest.(check bool)
226 Added: "menu carries a sign-out form" true
227 Added: (contains ~substring:"action=\"/logout\"" panel);
228 Added: Alcotest.(check bool)
229 Added: "the menu sign-out is CSRF-protected" true
230 Added: (contains ~substring:"dream.csrf" panel);
231 Added: Alcotest.(check bool)
232 Added: "the menu sign-out is labelled" true
233 Added: (contains ~substring:"Sign out" panel) );
234 Added: ( "auth pages omit the hamburger menu",
235 Added: `Quick,
236 Added: fun () ->
237 Added: let c = client () in
238 Added: let page = body (get c "/register") in
239 Added: Alcotest.(check bool)
240 Added: "no hamburger menu" false
241 Added: (contains ~substring:"class=\"menu\"" page) );
164 242 ( "the sign-in page renders a username field",
165 243 `Quick,
166 244 fun () ->
@@ -243,6 +321,9 @@
243 321 Alcotest.(check int) "workout started" 303 (status started);
244 322 let workout_page = body (get c "/workout") in
245 323 Alcotest.(check bool)
324 Added: "renders Day 1 as the page heading" true
325 Added: (contains ~substring:"<h1>Day 1</h1>" workout_page);
326 Added: Alcotest.(check bool)
246 327 "renders a vertical exercise button group" true
247 328 (contains ~substring:"exercise-group" workout_page);
248 329 Alcotest.(check bool)
@@ -283,8 +364,13 @@
283 364 (contains ~substring:"Description" page);
284 365 Alcotest.(check bool)
285 366 "includes prescription" true
286 Removed: (contains ~substring:"Dumbbell Flyes into Incline Presses" page)
287 Removed: );
367 Added: (contains ~substring:"Dumbbell Flyes into Incline Presses" page);
368 Added: Alcotest.(check bool)
369 Added: "shows primary navigation" true
370 Added: (contains ~substring:"primary-nav" page);
371 Added: Alcotest.(check bool)
372 Added: "shows bottom navigation" true
373 Added: (contains ~substring:"bottom-nav" page) );
288 374 ( "effort forms lay load and reps out side by side",
289 375 `Quick,
290 376 fun () ->
@@ -468,6 +554,116 @@
468 554 Alcotest.(check int) "logout redirect" 303 (status out);
469 555 let after = get c "/" in
470 556 Alcotest.(check int) "overview now redirects" 303 (status after) );
557 Added: ( "the current workout offers a CSRF-protected cancel control",
558 Added: `Quick,
559 Added: fun () ->
560 Added: let c = client () in
561 Added: let _ = sign_in_new c in
562 Added: let token = Option.get (csrf_token (body (get c "/"))) in
563 Added: let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
564 Added: let token = Option.get (csrf_token (body (get c "/"))) in
565 Added: let _ =
566 Added: post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
567 Added: in
568 Added: let page = body (get c "/workout") in
569 Added: Alcotest.(check bool)
570 Added: "posts to the cancel route" true
571 Added: (contains ~substring:"action=\"/workout/cancel\"" page);
572 Added: Alcotest.(check bool)
573 Added: "labels the control Cancel logging" true
574 Added: (contains ~substring:"Cancel logging" page);
575 Added: Alcotest.(check bool)
576 Added: "renders it as a secondary submit" true
577 Added: (contains ~substring:"class=\"secondary\"" page) );
578 Added: ( "cancelling a workout without a CSRF token is refused",
579 Added: `Quick,
580 Added: fun () ->
581 Added: let c = client () in
582 Added: let _ = sign_in_new c in
583 Added: let token = Option.get (csrf_token (body (get c "/"))) in
584 Added: let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
585 Added: let token = Option.get (csrf_token (body (get c "/"))) in
586 Added: let _ =
587 Added: post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
588 Added: in
589 Added: let response = post c "/workout/cancel" [ (* no dream.csrf *) ] in
590 Added: Alcotest.(check bool)
591 Added: "not a redirect to success" true
592 Added: (status response <> 303);
593 Added: (* The workout is untouched: it is still in progress. *)
594 Added: Alcotest.(check int)
595 Added: "workout still in progress" 200
596 Added: (status (get c "/workout")) );
597 Added: ( "cancelling after recording a set clears the workout and saves no \
598 Added: history",
599 Added: `Quick,
600 Added: fun () ->
601 Added: let c = client () in
602 Added: let _ = sign_in_new c in
603 Added: let token = Option.get (csrf_token (body (get c "/"))) in
604 Added: let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
605 Added: let token = Option.get (csrf_token (body (get c "/"))) in
606 Added: let _ =
607 Added: post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
608 Added: in
609 Added: (* Record the single Laterals set at slot 1. *)
610 Added: let token =
611 Added: Option.get (csrf_token (body (get c "/workout?slot=1")))
612 Added: in
613 Added: let recorded =
614 Added: post c "/workout/slots/1"
615 Added: [
616 Added: ("dream.csrf", token);
617 Added: ("load", "12");
618 Added: ("reps", "8");
619 Added: ("extension", "");
620 Added: ]
621 Added: in
622 Added: Alcotest.(check int) "recorded a set" 303 (status recorded);
623 Added: (* Cancel with a valid token. It redirects home. *)
624 Added: let token = Option.get (csrf_token (body (get c "/workout"))) in
625 Added: let cancelled =
626 Added: post c "/workout/cancel" [ ("dream.csrf", token) ]
627 Added: in
628 Added: Alcotest.(check int) "cancel redirects home" 303 (status cancelled);
629 Added: Alcotest.(check bool)
630 Added: "redirects to the overview, not history" true
631 Added: (List.mem "/" (Dream.headers cancelled "Location"));
632 Added: (* No current workout: the workout view redirects away. *)
633 Added: Alcotest.(check int)
634 Added: "no workout in progress" 303
635 Added: (status (get c "/workout"));
636 Added: (* No history record: the history view holds no saved workout. *)
637 Added: let history_page = body (get c "/history") in
638 Added: Alcotest.(check bool)
639 Added: "history shows no saved workout" false
640 Added: (contains ~substring:"/history/w1" history_page) );
641 Added: ( "the saved history view does not offer a cancel control",
642 Added: `Quick,
643 Added: fun () ->
644 Added: let c = client () in
645 Added: let _ = sign_in_new c in
646 Added: let token = Option.get (csrf_token (body (get c "/"))) in
647 Added: let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
648 Added: let token = Option.get (csrf_token (body (get c "/"))) in
649 Added: let _ =
650 Added: post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
651 Added: in
652 Added: let token = Option.get (csrf_token (body (get c "/workout"))) in
653 Added: let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
654 Added: let record_page = body (get c "/history/w1") in
655 Added: Alcotest.(check bool)
656 Added: "the saved view has no cancel route" false
657 Added: (contains ~substring:"/workout/cancel" record_page);
658 Added: Alcotest.(check bool)
659 Added: "the saved view has no cancel label" false
660 Added: (contains ~substring:"Cancel logging" record_page);
661 Added: Alcotest.(check bool)
662 Added: "shows primary navigation" true
663 Added: (contains ~substring:"primary-nav" record_page);
664 Added: Alcotest.(check bool)
665 Added: "shows bottom navigation" true
666 Added: (contains ~substring:"bottom-nav" record_page) );
471 667 ] );
472 668 ]
473 669