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.

Commit
2148e08d0229f949fdca9dc9fa9ae4689aa61809
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ENHANCEMENTS.org
index 26848784..d5075d09 100644..100644
@@ -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
index e144afda..9d7efbba 100644..100644
@@ -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
index 4ef4bd41..f67ae962 100644..100644
@@ -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 () ->