[OCaml] High Intensity Training Online
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.
ARCHITECTURE.org
@@ -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
@@ -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
@@ -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
@@ -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 () ->