feat preserve partial subjective feedback

Add a Back action to the feedback flow that steps to the previous factor and keeps every answer, so a trainee can revise an earlier report without losing the rest. Skip already keeps the other answers and ignores only the current factor. Make the modal Close control cancel the flow by posting the cancel route, so closing discards partial progress rather than hiding it. A report saves as a partial list: only the answered factors are recorded. The logbook records what was reported; it does not demand a full set.

Commit
6fde451ead9245a8a3601c9337cfbccf8eb5cfb2
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index d1799c35..22f5d10e 100644..100644
@@ -223,7 +223,9 @@
223 223 duplicate categories rejected. Leveled signals carry a five-point score, 1 (very
224 224 poor) to 5 (very good); these are wellness self-reports, never a measure of set
225 225 intensity. The application stores reports independently of workouts, and the web
226 Removed: tier records them through a sequential optional flow.
226 Added: tier records them through a sequential optional flow. The flow keeps its partial
227 Added: answers: Back and Skip preserve them, Close cancels the flow, and a report may be
228 Added: saved as a partial list.
227 229
228 230 ** Progression — the judgment
229 231
lib/web/handlers.ml
index dcc67d3f..1558db2b 100644..100644
@@ -576,12 +576,21 @@
576 576 let action =
577 577 match field "action" fields with
578 578 | Some ("next" | "Next") -> Some "next"
579 Added: | Some ("back" | "Back") -> Some "back"
579 580 | Some ("skip" | "Skip this factor" | "Skip this step") ->
580 581 Some "skip"
581 582 | Some ("save" | "Record feedback") -> Some "save"
582 583 | _ -> None
583 584 in
584 Removed: if step = 5 then
585 Added: (* Back steps to the previous factor and keeps every answer, so a
586 Added: trainee can revise an earlier report without losing the rest. *)
587 Added: if action = Some "back" then
588 Added: if step = 0 then redirect_to request Routes.logbook
589 Added: else
590 Added: let previous = Pages.{ flow with step = step - 1 } in
591 Added: set_feedback_flow request previous >>= fun () ->
592 Added: redirect_to request Routes.logbook
593 Added: else if step = 5 then
585 594 if action = Some "skip" then
586 595 finish_feedback t trainee request flow []
587 596 else if action = Some "save" then
lib/web/pages.ml
index 3a77063c..532d01f9 100644..100644
@@ -1265,11 +1265,17 @@
1265 1265 feedback_progress (step + 1);
1266 1266 tag "div"
1267 1267 [ class_ "feedback-actions" ]
1268 Removed: [
1269 Removed: feedback_action_button ~action:"next" ~label:"Next" ();
1270 Removed: feedback_action_button ~action:"skip" ~label:"Skip this factor"
1271 Removed: ~secondary:true ();
1272 Removed: ];
1268 Added: ((if step > 0 then
1269 Added: [
1270 Added: feedback_action_button ~action:"back" ~label:"Back"
1271 Added: ~secondary:true ();
1272 Added: ]
1273 Added: else [])
1274 Added: @ [
1275 Added: feedback_action_button ~action:"next" ~label:"Next" ();
1276 Added: feedback_action_button ~action:"skip" ~label:"Skip this factor"
1277 Added: ~secondary:true ();
1278 Added: ]);
1273 1279 ]
1274 1280 else
1275 1281 fields
@@ -1288,6 +1294,8 @@
1288 1294 tag "div"
1289 1295 [ class_ "feedback-actions" ]
1290 1296 [
1297 Added: feedback_action_button ~action:"back" ~label:"Back"
1298 Added: ~secondary:true ();
1291 1299 feedback_action_button ~action:"save" ~label:"Record feedback" ();
1292 1300 feedback_action_button ~action:"skip" ~label:"Skip this step"
1293 1301 ~secondary:true ();
@@ -1315,14 +1323,25 @@
1315 1323 [ class_ "feedback-modal-heading" ]
1316 1324 [
1317 1325 tag "h2" [] [ txt "How are you feeling?" ];
1318 Removed: tag "button"
1326 Added: (* Close cancels the flow: it submits the cancel form, so the
1327 Added: partial progress is discarded rather than kept for later. *)
1328 Added: tag "form"
1319 1329 [
1320 Removed: type_ "button";
1321 Removed: class_ "feedback-modal-close";
1322 Removed: Dream_html.attr "data-hito-feedback-dismiss";
1323 Removed: Dream_html.string_attr "aria-label" "Close feedback";
1330 Added: action Routes.cancel_feedback;
1331 Added: post_form;
1332 Added: class_ "feedback-close-form";
1333 Added: Dream_html.attr "data-hito-app-form";
1324 1334 ]
1325 Removed: [ txt "×" ];
1335 Added: [
1336 Added: Dream_html.csrf_tag request;
1337 Added: tag "button"
1338 Added: [
1339 Added: type_ "submit";
1340 Added: class_ "feedback-modal-close";
1341 Added: Dream_html.string_attr "aria-label" "Close feedback";
1342 Added: ]
1343 Added: [ txt "×" ];
1344 Added: ];
1326 1345 ];
1327 1346 feedback_flow_form request flow;
1328 1347 feedback_cancel_form request;
test/test_web.ml
index d58c5ba3..a7b7ed3a 100644..100644
@@ -1181,6 +1181,105 @@
1181 1181 Alcotest.(check bool)
1182 1182 "does not add an empty report" false
1183 1183 (contains ~substring:"No signals" logbook_page) );
1184 Added: ( "back returns to the previous factor and keeps answers",
1185 Added: `Quick,
1186 Added: fun () ->
1187 Added: let c = client () in
1188 Added: let _ = sign_in_new c in
1189 Added: let start = body (get c "/logbook?feedback=start") in
1190 Added: Alcotest.(check bool)
1191 Added: "the first factor offers no back control" false
1192 Added: (contains ~substring:"value=\"back\"" start);
1193 Added: let token = Option.get (csrf_token start) in
1194 Added: let _ =
1195 Added: post c "/feedback"
1196 Added: [
1197 Added: ("dream.csrf", token);
1198 Added: ("step", "0");
1199 Added: ("action", "next");
1200 Added: ("choice", "5");
1201 Added: ]
1202 Added: in
1203 Added: let appetite = body (get c "/logbook") in
1204 Added: Alcotest.(check bool)
1205 Added: "the second factor offers a back control" true
1206 Added: (contains ~substring:">Appetite</h3>" appetite
1207 Added: && contains ~substring:"value=\"back\"" appetite);
1208 Added: let token = Option.get (csrf_token appetite) in
1209 Added: let back =
1210 Added: post c "/feedback"
1211 Added: [ ("dream.csrf", token); ("step", "1"); ("action", "back") ]
1212 Added: in
1213 Added: Alcotest.(check int) "back redirects" 303 (status back);
1214 Added: let sleep_again = body (get c "/logbook") in
1215 Added: Alcotest.(check bool)
1216 Added: "back returns to the first factor" true
1217 Added: (contains ~substring:">Sleep</h3>" sleep_again
1218 Added: && contains ~substring:"1 of 5 factors" sleep_again);
1219 Added: (* The earlier answer survives Back. Skipping forward from here
1220 Added: never touches it, so saving still stores the sleep signal. *)
1221 Added: let rec skip_to_notes page =
1222 Added: let token = Option.get (csrf_token page) in
1223 Added: let step_of =
1224 Added: if contains ~substring:">Sleep</h3>" page then 0
1225 Added: else if contains ~substring:">Appetite</h3>" page then 1
1226 Added: else if contains ~substring:">Readiness</h3>" page then 2
1227 Added: else if contains ~substring:">Motivation</h3>" page then 3
1228 Added: else if contains ~substring:">Perceived difficulty</h3>" page
1229 Added: then 4
1230 Added: else -1
1231 Added: in
1232 Added: if step_of < 0 then page
1233 Added: else begin
1234 Added: let _ =
1235 Added: post c "/feedback"
1236 Added: [
1237 Added: ("dream.csrf", token);
1238 Added: ("step", string_of_int step_of);
1239 Added: ("action", "skip");
1240 Added: ]
1241 Added: in
1242 Added: skip_to_notes (body (get c "/logbook"))
1243 Added: end
1244 Added: in
1245 Added: let notes = skip_to_notes sleep_again in
1246 Added: let token = Option.get (csrf_token notes) in
1247 Added: let _ =
1248 Added: post c "/feedback"
1249 Added: [ ("dream.csrf", token); ("step", "5"); ("action", "save") ]
1250 Added: in
1251 Added: let logbook_page = body (get c "/logbook") in
1252 Added: Alcotest.(check bool)
1253 Added: "the pre-back answer persists" true
1254 Added: (contains ~substring:"Sleep 5 (very good)" logbook_page) );
1255 Added: ( "closing the feedback modal cancels the flow",
1256 Added: `Quick,
1257 Added: fun () ->
1258 Added: let c = client () in
1259 Added: let _ = sign_in_new c in
1260 Added: let start = body (get c "/logbook?feedback=start") in
1261 Added: (* Answer the first factor, then close: the flow is discarded and
1262 Added: reopening starts fresh at the first factor. *)
1263 Added: let token = Option.get (csrf_token start) in
1264 Added: let _ =
1265 Added: post c "/feedback"
1266 Added: [
1267 Added: ("dream.csrf", token);
1268 Added: ("step", "0");
1269 Added: ("action", "next");
1270 Added: ("choice", "5");
1271 Added: ]
1272 Added: in
1273 Added: let appetite = body (get c "/logbook") in
1274 Added: let token = Option.get (csrf_token appetite) in
1275 Added: (* The close control posts to the cancel route. *)
1276 Added: let closed = post c "/feedback/cancel" [ ("dream.csrf", token) ] in
1277 Added: Alcotest.(check int) "close redirects" 303 (status closed);
1278 Added: let reopened = body (get c "/logbook?feedback=start") in
1279 Added: Alcotest.(check bool)
1280 Added: "reopening starts fresh at the first factor" true
1281 Added: (contains ~substring:">Sleep</h3>" reopened
1282 Added: && contains ~substring:"1 of 5 factors" reopened) );
1184 1283 ( "submitting feedback stores it and it appears on the logbook",
1185 1284 `Quick,
1186 1285 fun () ->