[OCaml] High Intensity Training Online
feat make workout cancellation save-and-exit
Cancelling the current workout now always leaves the view cleanly. The handler saves the workout in progress as-is — an incomplete record the logbook keeps for later editing — and redirects Home. It never renders an invalid-form response; a forged token simply skips the save and still redirects Home. This departs from the doctrine note that an abandoned session leaves no record. The maintainer requested that a cancelled workout be saved for later editing. Saving an incomplete record adds no volume and keeps plan and record distinct, so it does not breach a training invariant. The service-level discard (Service.cancel) remains available.
Changed files
ENHANCEMENTS.org
@@ -38,7 +38,7 @@
38
38
When the user is logging a current workout, show a Home card that indicates the workout is in progress and links to the Current Workout tab.
39
39
** DONE Harmonize UI corner radii
40
40
Use uniformly rounded corners for all buttons, cards, and other UI elements.
41
Removed:
** TODO Make workout cancellation unconditional
41
Added:
** DONE Make workout cancellation unconditional
42
42
When cancelling a workout, always leave the current workout view without showing an invalid-form response. Save the workout log as-is for later editing, then redirect the user to Home.
43
43
** TODO Remove thick black bars from mobile bottom navigation
44
44
Remove the thick black bars visible in the mobile bottom navigation.
@@ -48,3 +48,5 @@
48
48
Use a dropdown menu to select the exercise instead of the exercise button group.
49
49
** TODO Remove the boulder top border from exercise logging cards
50
50
Do not show a boulder top fieldset border on the exercise logging fieldset card.
51
Added:
** TODO Increase bottom-navigation button text size
52
Added:
Increase the bottom-navigation button text font size to better support WCAG-aligned readability.
lib/web/handlers.ml
@@ -368,16 +368,17 @@
368
368
| Some _ -> redirect_to request Routes.logbook
369
369
| None -> not_found Present.no_workout)
370
370
371
Removed:
(* Discard the workout in progress and return home. CSRF-guarded like
372
Removed:
finish. The service clears only the in-progress slot, so saved history is
373
Removed:
untouched. *)
371
Added:
(* Leave the current workout unconditionally. Cancelling never shows an
372
Added:
invalid-form response: the workout in progress is saved as-is — an
373
Added:
incomplete record the logbook keeps for later editing — and the user is
374
Added:
sent Home. A forged or missing CSRF token skips the save but still
375
Added:
redirects Home, so the view is always left cleanly. *)
374
376
let cancel t trainee request =
375
377
guard_csrf request >>= function
376
Removed:
| Error _ -> bad_request Present.form_invalid
377
Removed:
| Ok () -> (
378
Removed:
Service.cancel t.service trainee.Trainee.id >>= function
379
Removed:
| true -> redirect_to request Routes.home
380
Removed:
| false -> not_found Present.no_workout)
378
Added:
| Error _ -> redirect_to request Routes.home
379
Added:
| Ok () ->
380
Added:
Service.finish t.service trainee.Trainee.id ~ended_at:(t.now ())
381
Added:
>>= fun _ -> redirect_to request Routes.home
381
382
382
383
let routes t =
383
384
[
test/test_web.ml
@@ -769,7 +769,7 @@
769
769
Alcotest.(check bool)
770
770
"renders it as a secondary submit" true
771
771
(contains ~substring:"class=\"secondary\"" page) );
772
Removed:
( "cancelling a workout without a CSRF token is refused",
772
Added:
( "cancelling a workout without a CSRF token leaves the view cleanly",
773
773
`Quick,
774
774
fun () ->
775
775
let c = client () in
@@ -781,15 +781,18 @@
781
781
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
782
782
in
783
783
let response = post c "/workout/cancel" [ (* no dream.csrf *) ] in
784
Added:
(* No invalid-form response: cancelling always redirects home. A
785
Added:
forged or missing token skips the save, so the workout is left
786
Added:
untouched and still in progress. *)
787
Added:
Alcotest.(check int)
788
Added:
"redirects home without an error page" 303 (status response);
784
789
Alcotest.(check bool)
785
Removed:
"not a redirect to success" true
786
Removed:
(status response <> 303);
787
Removed:
(* The workout is untouched: it is still in progress. *)
790
Added:
"redirects to the overview" true
791
Added:
(List.mem "/" (Dream.headers response "Location"));
788
792
Alcotest.(check int)
789
793
"workout still in progress" 200
790
794
(status (get c "/workout")) );
791
Removed:
( "cancelling after recording a set clears the workout and saves no \
792
Removed:
history",
795
Added:
( "cancelling after recording a set saves the workout for later editing",
793
796
`Quick,
794
797
fun () ->
795
798
let c = client () in
@@ -814,24 +817,29 @@
814
817
]
815
818
in
816
819
Alcotest.(check int) "recorded a set" 303 (status recorded);
817
Removed:
(* Cancel with a valid token. It redirects home. *)
820
Added:
(* Cancel with a valid token. It saves the log as-is and redirects
821
Added:
home. *)
818
822
let token = Option.get (csrf_token (body (get c "/workout"))) in
819
823
let cancelled =
820
824
post c "/workout/cancel" [ ("dream.csrf", token) ]
821
825
in
822
826
Alcotest.(check int) "cancel redirects home" 303 (status cancelled);
823
827
Alcotest.(check bool)
824
Removed:
"redirects to the overview, not history" true
828
Added:
"redirects to the overview, not the logbook" true
825
829
(List.mem "/" (Dream.headers cancelled "Location"));
826
830
(* No current workout: the workout view redirects away. *)
827
831
Alcotest.(check int)
828
832
"no workout in progress" 303
829
833
(status (get c "/workout"));
830
Removed:
(* No logbook record: the logbook view holds no saved workout. *)
834
Added:
(* The workout was saved as-is: the logbook holds the record and it
835
Added:
opens for later editing. *)
831
836
let logbook_page = body (get c "/logbook") in
832
837
Alcotest.(check bool)
833
Removed:
"logbook shows no saved workout" false
834
Removed:
(contains ~substring:"/logbook/w1" logbook_page) );
838
Added:
"logbook shows the saved workout" true
839
Added:
(contains ~substring:"/logbook/w1" logbook_page);
840
Added:
Alcotest.(check int)
841
Added:
"the saved workout opens for editing" 200
842
Added:
(status (get c "/logbook/w1")) );
835
843
( "the saved logbook view does not offer a cancel control",
836
844
`Quick,
837
845
fun () ->