[OCaml] High Intensity Training Online
feat Improve workout controls and navigation
Add cancellation controls, responsive menu navigation, and consistent authenticated page affordances.
Changed files
lib/web/assets/hito.css
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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