feat graph subjective feedback over time on the logbook

Draw an inline-SVG line chart of the leveled feedback scores on the logbook, one polyline per factor, with a score axis and a legend. It needs no script and no dependency: the logbook already holds the reports, so the chart only renders what was observed. It appears once at least two reports carry a factor, since a single point is not a trend.

Commit
71580da444be8aeb0b2d371a8d60d3f17f4fa3e3
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index 22f5d10e..bf336a5b 100644..100644
@@ -225,7 +225,8 @@
225 225 intensity. The application stores reports independently of workouts, and the web
226 226 tier records them through a sequential optional flow. The flow keeps its partial
227 227 answers: Back and Skip preserve them, Close cancels the flow, and a report may be
228 Removed: saved as a partial list.
228 Added: saved as a partial list. The logbook draws a small inline-SVG time graph of the
229 Added: leveled scores, one line per factor, once two reports carry a factor.
229 230
230 231 ** Progression — the judgment
231 232
lib/web/assets/hito.css
index ac3cbd9c..17a0e05b 100644..100644
@@ -665,6 +665,66 @@
665 665 list-style: disc;
666 666 }
667 667
668 Added: /* The feedback time graph. A quiet inline chart on the paper surface, one
669 Added: coloured line per subjective factor, with a small legend. */
670 Added: .feedback-graph {
671 Added: margin: 1.25rem 0 0;
672 Added: }
673 Added: .feedback-graph figcaption {
674 Added: font: 600 var(--font-size-base)/1.2 var(--sans);
675 Added: margin-bottom: 0.5rem;
676 Added: }
677 Added: .feedback-graph-svg {
678 Added: display: block;
679 Added: width: 100%;
680 Added: max-width: 32rem;
681 Added: height: auto;
682 Added: border: 1px solid var(--rule);
683 Added: border-radius: var(--radius);
684 Added: background: var(--paper-raised);
685 Added: }
686 Added: .feedback-graph-tick {
687 Added: font: var(--font-size-small)/1 var(--mono);
688 Added: fill: var(--muted-ink);
689 Added: }
690 Added: .feedback-graph-line {
691 Added: stroke-width: 2;
692 Added: stroke-linejoin: round;
693 Added: stroke-linecap: round;
694 Added: }
695 Added: /* One hue per factor, drawn in document order to match the legend. */
696 Added: .feedback-graph-line[data-factor="Sleep"] { stroke: var(--oxblood); }
697 Added: .feedback-graph-line[data-factor="Appetite"] { stroke: var(--brass); }
698 Added: .feedback-graph-line[data-factor="Readiness"] { stroke: #2f5d50; }
699 Added: .feedback-graph-line[data-factor="Motivation"] { stroke: #3a4a7a; }
700 Added: .feedback-graph-line[data-factor="Perceived difficulty"] { stroke: var(--ink); }
701 Added: .feedback-graph-legend {
702 Added: display: flex;
703 Added: flex-wrap: wrap;
704 Added: gap: 0.75rem;
705 Added: margin: 0.5rem 0 0;
706 Added: padding: 0;
707 Added: list-style: none;
708 Added: font: var(--font-size-small)/1.2 var(--sans);
709 Added: }
710 Added: .feedback-graph-legend li {
711 Added: display: inline-flex;
712 Added: align-items: center;
713 Added: gap: 0.35rem;
714 Added: }
715 Added: .feedback-graph-legend li::before {
716 Added: content: "";
717 Added: width: 0.9rem;
718 Added: height: 0.2rem;
719 Added: border-radius: var(--radius);
720 Added: background: var(--ink);
721 Added: }
722 Added: .feedback-graph-legend li[data-factor="Sleep"]::before { background: var(--oxblood); }
723 Added: .feedback-graph-legend li[data-factor="Appetite"]::before { background: var(--brass); }
724 Added: .feedback-graph-legend li[data-factor="Readiness"]::before { background: #2f5d50; }
725 Added: .feedback-graph-legend li[data-factor="Motivation"]::before { background: #3a4a7a; }
726 Added: .feedback-graph-legend li[data-factor="Perceived difficulty"]::before { background: var(--ink); }
727 Added:
668 728 .warn {
669 729 max-width: 47rem;
670 730 margin: 1rem 0;
lib/web/pages.ml
index 532d01f9..8a0662e7 100644..100644
@@ -1366,6 +1366,120 @@
1366 1366 | Injury -> "Injury"
1367 1367 | Preparation_insufficient -> "Preparation insufficient"
1368 1368
1369 Added: (* The scored level a report gives one factor, if any. Only leveled factors
1370 Added: plot on the graph; flags such as pain are events, not scores. *)
1371 Added: let factor_score (field : string) (report : Evidence.Feedback.t) =
1372 Added: let of_signal (signal : Evidence.Feedback.signal) =
1373 Added: match (field, signal) with
1374 Added: | "sleep", Sleep l
1375 Added: | "appetite", Appetite l
1376 Added: | "readiness", Readiness l
1377 Added: | "motivation", Motivation l
1378 Added: | "difficulty", Difficulty l ->
1379 Added: Some (Evidence.Feedback.level_to_score l)
1380 Added: | _ -> None
1381 Added: in
1382 Added: List.find_map of_signal (Evidence.Feedback.signals report)
1383 Added:
1384 Added: (* A small inline-SVG line chart of leveled feedback over time, one polyline per
1385 Added: factor. No script and no dependency: the logbook records, and this only draws
1386 Added: what it already holds. Shown once at least two reports carry a leveled
1387 Added: factor, since a single point is not a trend. *)
1388 Added: let feedback_graph reports =
1389 Added: let chronological =
1390 Added: List.sort
1391 Added: (fun a b ->
1392 Added: Int.compare
1393 Added: (Recovery.timestamp_to_unix_seconds (Evidence.Feedback.reported_at a))
1394 Added: (Recovery.timestamp_to_unix_seconds (Evidence.Feedback.reported_at b)))
1395 Added: reports
1396 Added: in
1397 Added: let count = List.length chronological in
1398 Added: let series =
1399 Added: List.map
1400 Added: (fun (field, label) ->
1401 Added: ( label,
1402 Added: List.mapi
1403 Added: (fun i report -> (i, factor_score field report))
1404 Added: chronological ))
1405 Added: feedback_factors
1406 Added: in
1407 Added: (* A factor charts only if it holds at least two scored points. *)
1408 Added: let plottable (_, points) =
1409 Added: List.length (List.filter (fun (_, s) -> Option.is_some s) points) >= 2
1410 Added: in
1411 Added: let series = List.filter plottable series in
1412 Added: if count < 2 || series = [] then []
1413 Added: else
1414 Added: let width = 480 and height = 180 in
1415 Added: let pad_left = 28 and pad_right = 12 and pad_top = 12 and pad_bottom = 24 in
1416 Added: let plot_w = width - pad_left - pad_right in
1417 Added: let plot_h = height - pad_top - pad_bottom in
1418 Added: let x_of i =
1419 Added: if count = 1 then pad_left + (plot_w / 2)
1420 Added: else pad_left + (i * plot_w / (count - 1))
1421 Added: in
1422 Added: (* Score 1..5 maps low-to-high, so 5 sits at the top. *)
1423 Added: let y_of score = pad_top + ((5 - score) * plot_h / 4) in
1424 Added: let polyline label points =
1425 Added: let coords =
1426 Added: List.filter_map
1427 Added: (fun (i, s) ->
1428 Added: Option.map (fun s -> Printf.sprintf "%d,%d" (x_of i) (y_of s)) s)
1429 Added: points
1430 Added: in
1431 Added: tag "polyline"
1432 Added: [
1433 Added: class_ "feedback-graph-line";
1434 Added: Dream_html.string_attr "points" "%s" (String.concat " " coords);
1435 Added: Dream_html.string_attr "fill" "none";
1436 Added: Dream_html.string_attr "data-factor" "%s" label;
1437 Added: ]
1438 Added: []
1439 Added: in
1440 Added: let axis =
1441 Added: List.map
1442 Added: (fun score ->
1443 Added: let y = y_of score in
1444 Added: tag "text"
1445 Added: [
1446 Added: class_ "feedback-graph-tick";
1447 Added: Dream_html.string_attr "x" "%d" (pad_left - 6);
1448 Added: Dream_html.string_attr "y" "%d" (y + 3);
1449 Added: Dream_html.string_attr "text-anchor" "end";
1450 Added: ]
1451 Added: [ txt "%d" score ])
1452 Added: [ 1; 2; 3; 4; 5 ]
1453 Added: in
1454 Added: let legend =
1455 Added: tag "ul"
1456 Added: [ class_ "feedback-graph-legend" ]
1457 Added: (List.map
1458 Added: (fun (label, _) ->
1459 Added: tag "li"
1460 Added: [ Dream_html.string_attr "data-factor" "%s" label ]
1461 Added: [ txt "%s" label ])
1462 Added: series)
1463 Added: in
1464 Added: [
1465 Added: tag "figure"
1466 Added: [ class_ "feedback-graph" ]
1467 Added: [
1468 Added: tag "figcaption" [] [ txt "Feedback over time" ];
1469 Added: tag "svg"
1470 Added: [
1471 Added: class_ "feedback-graph-svg";
1472 Added: Dream_html.string_attr "viewBox" "0 0 %d %d" width height;
1473 Added: Dream_html.string_attr "role" "img";
1474 Added: Dream_html.string_attr "aria-label"
1475 Added: "Subjective feedback scores over time, one line per factor";
1476 Added: ]
1477 Added: (axis
1478 Added: @ List.map (fun (label, points) -> polyline label points) series);
1479 Added: legend;
1480 Added: ];
1481 Added: ]
1482 Added:
1369 1483 let feedback_list reports =
1370 1484 if reports = [] then []
1371 1485 else
@@ -1442,7 +1556,7 @@
1442 1556 [ txt "Record feedback" ];
1443 1557 feedback_modal request ~flow:feedback_flow ~open_:feedback_open;
1444 1558 ]
1445 Removed: @ feedback_list feedback);
1559 Added: @ feedback_graph feedback @ feedback_list feedback);
1446 1560 ]
1447 1561
1448 1562 (* The authenticated application feedback page. It keeps writing and reviewing
test/test_web.ml
index a7b7ed3a..bf35e75c 100644..100644
@@ -1319,6 +1319,61 @@
1319 1319 Alcotest.(check bool)
1320 1320 "shows the reported pain signal" true
1321 1321 (contains ~substring:"Pain" logbook_page) );
1322 Added: ( "the logbook graphs leveled feedback once two reports exist",
1323 Added: `Quick,
1324 Added: fun () ->
1325 Added: let c = client () in
1326 Added: let _ = sign_in_new c in
1327 Added: (* Record one full feedback report: answer sleep, skip the rest. *)
1328 Added: let record_sleep score =
1329 Added: let start = body (get c "/logbook?feedback=start") in
1330 Added: let token = Option.get (csrf_token start) in
1331 Added: let _ =
1332 Added: post c "/feedback"
1333 Added: [
1334 Added: ("dream.csrf", token);
1335 Added: ("step", "0");
1336 Added: ("action", "next");
1337 Added: ("choice", score);
1338 Added: ]
1339 Added: in
1340 Added: let rec skip step =
1341 Added: if step < 5 then begin
1342 Added: let token =
1343 Added: Option.get (csrf_token (body (get c "/logbook")))
1344 Added: in
1345 Added: let _ =
1346 Added: post c "/feedback"
1347 Added: [
1348 Added: ("dream.csrf", token);
1349 Added: ("step", string_of_int step);
1350 Added: ("action", "skip");
1351 Added: ]
1352 Added: in
1353 Added: skip (step + 1)
1354 Added: end
1355 Added: in
1356 Added: skip 1;
1357 Added: let token = Option.get (csrf_token (body (get c "/logbook"))) in
1358 Added: ignore
1359 Added: (post c "/feedback"
1360 Added: [ ("dream.csrf", token); ("step", "5"); ("action", "save") ])
1361 Added: in
1362 Added: (* One report: no graph yet, a single point is not a trend. *)
1363 Added: record_sleep "2";
1364 Added: let one = body (get c "/logbook") in
1365 Added: Alcotest.(check bool)
1366 Added: "no graph for a single report" false
1367 Added: (contains ~substring:"feedback-graph" one);
1368 Added: (* A second report: the graph appears with an SVG polyline. *)
1369 Added: record_sleep "4";
1370 Added: let two = body (get c "/logbook") in
1371 Added: Alcotest.(check bool)
1372 Added: "graphs feedback over time" true
1373 Added: (contains ~substring:"feedback-graph" two
1374 Added: && contains ~substring:"<svg" two
1375 Added: && contains ~substring:"<polyline" two
1376 Added: && contains ~substring:"Feedback over time" two) );
1322 1377 ( "finishing a workout suggests feedback on the logbook",
1323 1378 `Quick,
1324 1379 fun () ->