[OCaml] High Intensity Training Online
feat unify Logbook calendar and progress tabs
Keep both Logbook views under one endpoint so the page remains bookmarkable and usable without JavaScript. Remove the redundant progress route and preserve the existing chart content in the Progress tab.
Changed files
lib/presenter/dune
@@ -12,7 +12,6 @@
12
12
pages_routine
13
13
pages_workout
14
14
pages_logbook
15
Removed:
pages_progress
16
15
svg_chart
17
16
pages_app_feedback
18
17
pages_profile
lib/presenter/pages.ml
@@ -15,7 +15,6 @@
15
15
let workout = Pages_workout.workout
16
16
let feedback_factors = Pages_logbook.feedback_factors
17
17
let logbook = Pages_logbook.logbook
18
Removed:
let progress = Pages_progress.progress
19
18
let app_feedback = Pages_app_feedback.app_feedback
20
19
let profile = Pages_profile.profile
21
20
let settings = Pages_settings.settings
lib/presenter/pages.mli
@@ -66,19 +66,13 @@
66
66
?suggest_feedback:bool ->
67
67
?feedback_open:bool ->
68
68
?delete_record:View_model.Logbook_entry.t ->
69
Added:
?tab:string ->
70
Added:
?progress_charts:View_model.Progress_page.chart list ->
69
71
unit ->
70
72
page
71
Removed:
(** The logbook: a month calendar with a selected-day detail panel. The panel
72
Removed:
shows recorded workouts and the planned workout for the selected day. *)
73
Removed:
74
Removed:
val progress :
75
Removed:
Dream.request ->
76
Removed:
?workout_started:int ->
77
Removed:
viewer:View_model.Viewer.t ->
78
Removed:
View_model.Progress_page.t ->
79
Removed:
page
80
Removed:
(** The per-exercise progress page. One dual-axis line chart per exercise with
81
Removed:
at least two observations in the merged native and imported history. *)
73
Added:
(** The logbook: the Calendar and Progress views under one endpoint. The [tab]
74
Added:
parameter selects the active view. The [progress_charts] parameter supplies
75
Added:
chart data for the Progress view. *)
82
76
83
77
val profile :
84
78
Dream.request ->
lib/presenter/pages_logbook.ml
@@ -89,11 +89,7 @@
89
89
[ class_ "confirm-dialog"; Dream_html.attr "open" ]
90
90
[
91
91
tag "h2" [] [ txt "Delete this workout log?" ];
92
Removed:
tag "p" []
93
Removed:
[
94
Removed:
txt "Delete %s from %s. This cannot be undone." record.workout_name
95
Removed:
record.date;
96
Removed:
];
92
Added:
tag "p" [] [ txt "Delete %s. This cannot be undone." record.workout_name ];
97
93
tag "div"
98
94
[ class_ "button-group" ]
99
95
[
@@ -471,40 +467,93 @@
471
467
];
472
468
]
473
469
470
Added:
(* The logbook views are link tabs. They use one endpoint and remain usable
471
Added:
without JavaScript. *)
472
Added:
let logbook_tabs ~active_tab =
473
Added:
let tab_link ~tab ~label =
474
Added:
let active = String.equal tab active_tab in
475
Added:
tag "a"
476
Added:
([
477
Added:
Dream_html.string_attr "href" "/logbook?tab=%s" tab;
478
Added:
class_ "logbook-tab";
479
Added:
Dream_html.attr "data-hito-app-link";
480
Added:
]
481
Added:
@ if active then [ Dream_html.string_attr "aria-current" "page" ] else []
482
Added:
)
483
Added:
[ txt "%s" label ]
484
Added:
in
485
Added:
tag "nav"
486
Added:
[
487
Added:
class_ "logbook-tabs"; Dream_html.string_attr "aria-label" "Logbook views";
488
Added:
]
489
Added:
[
490
Added:
tab_link ~tab:"calendar" ~label:"Calendar";
491
Added:
tab_link ~tab:"progress" ~label:"Progress";
492
Added:
]
493
Added:
494
Added:
let progress_legend () =
495
Added:
tag "div"
496
Added:
[ class_ "chart-legend" ]
497
Added:
[
498
Added:
tag "span"
499
Added:
[ class_ "chart-legend-item weight" ]
500
Added:
[ txt "Weight (kg), left axis" ];
501
Added:
tag "span" [ class_ "chart-legend-item reps" ] [ txt "Reps, right axis" ];
502
Added:
]
503
Added:
504
Added:
let progress_chart_section (chart : View_model.Progress_page.chart) =
505
Added:
tag "section"
506
Added:
[ class_ "progress-chart-section" ]
507
Added:
[
508
Added:
tag "h2" [] [ txt "%s" chart.exercise_name ];
509
Added:
progress_legend ();
510
Added:
Svg_chart.render chart;
511
Added:
]
512
Added:
513
Added:
let progress_content charts =
514
Added:
match charts with
515
Added:
| [] ->
516
Added:
[
517
Added:
tag "p" []
518
Added:
[
519
Added:
txt
520
Added:
"No exercise has enough history to chart yet. Log or import at \
521
Added:
least two performances of an exercise to see its progress.";
522
Added:
];
523
Added:
]
524
Added:
| charts -> List.map progress_chart_section charts
525
Added:
474
526
let logbook request ?workout_started ~viewer ?calendar ?selected_day
475
Removed:
?(suggest_feedback = false) ?(feedback_open = false) ?delete_record () =
527
Added:
?(suggest_feedback = false) ?(feedback_open = false) ?delete_record
528
Added:
?(tab = "calendar") ?progress_charts () =
529
Added:
let active_tab =
530
Added:
if String.equal tab "progress" then "progress" else "calendar"
531
Added:
in
532
Added:
let content =
533
Added:
match active_tab with
534
Added:
| "calendar" ->
535
Added:
(match calendar with Some cal -> [ calendar_grid cal ] | None -> [])
536
Added:
@ [
537
Added:
(match selected_day with
538
Added:
| Some selected -> selected_day_panel selected
539
Added:
| None ->
540
Added:
tag "section"
541
Added:
[ class_ "selected-day empty-selected-day" ]
542
Added:
[ tag "p" [] [ txt "Select a day to view its workout." ] ]);
543
Added:
]
544
Added:
| "progress" -> Option.value progress_charts ~default:[] |> progress_content
545
Added:
| _ -> []
546
Added:
in
476
547
html_page ~viewer ~request ~active:"logbook" ?workout_started "Logbook"
477
548
([
478
Removed:
tag "div"
479
Removed:
[ class_ "page-heading-row" ]
480
Removed:
[
481
Removed:
tag "h1" [] [ txt "Logbook" ];
482
Removed:
tag "a"
483
Removed:
[
484
Removed:
href Routes.progress;
485
Removed:
class_ "page-heading-link";
486
Removed:
Dream_html.attr "data-hito-app-link";
487
Removed:
]
488
Removed:
[ txt "View progress" ];
489
Removed:
];
549
Added:
logbook_tabs ~active_tab;
490
550
(if suggest_feedback then
491
551
tag "p"
492
552
[ class_ "eyebrow" ]
493
553
[ txt "Workout saved — add feedback while it is fresh." ]
494
554
else txt "");
495
555
]
496
Removed:
@ (match calendar with Some cal -> [ calendar_grid cal ] | None -> [])
497
Removed:
@ [
498
Removed:
(match selected_day with
499
Removed:
| Some selected -> selected_day_panel selected
500
Removed:
| None ->
501
Removed:
tag "section"
502
Removed:
[ class_ "selected-day empty-selected-day" ]
503
Removed:
[ tag "p" [] [ txt "Select a day to view its workout." ] ]);
504
Removed:
]
505
Removed:
(* The subjective-feedback modal stays in the DOM so the Record feedback
506
Removed:
button opens it. The delete-confirm dialog appears only when an entry
507
Removed:
asked for it. *)
556
Added:
@ content
508
557
@ [ feedback_modal request ~open_:feedback_open ]
509
558
@
510
559
match delete_record with
lib/presenter/pages_progress.ml
@@ -1,38 +0,0 @@
1
Removed:
open Dream_html
2
Removed:
open Page_kit
3
Removed:
open Page_shell
4
Removed:
5
Removed:
let legend () =
6
Removed:
tag "div"
7
Removed:
[ class_ "chart-legend" ]
8
Removed:
[
9
Removed:
tag "span"
10
Removed:
[ class_ "chart-legend-item weight" ]
11
Removed:
[ txt "Weight (kg), left axis" ];
12
Removed:
tag "span" [ class_ "chart-legend-item reps" ] [ txt "Reps, right axis" ];
13
Removed:
]
14
Removed:
15
Removed:
let chart_section (chart : View_model.Progress_page.chart) =
16
Removed:
tag "section"
17
Removed:
[ class_ "progress-chart-section" ]
18
Removed:
[
19
Removed:
tag "h2" [] [ txt "%s" chart.exercise_name ];
20
Removed:
legend ();
21
Removed:
Svg_chart.render chart;
22
Removed:
]
23
Removed:
24
Removed:
let progress request ?workout_started ~viewer (vm : View_model.Progress_page.t)
25
Removed:
=
26
Removed:
html_page ~viewer ~request ~active:"logbook" ?workout_started "Progress"
27
Removed:
(tag "h1" [] [ txt "Progress" ]
28
Removed:
::
29
Removed:
(if vm.charts = [] then
30
Removed:
[
31
Removed:
tag "p" []
32
Removed:
[
33
Removed:
txt
34
Removed:
"No exercise has enough history to chart yet. Log or import at \
35
Removed:
least two performances of an exercise to see its progress.";
36
Removed:
];
37
Removed:
]
38
Removed:
else List.map chart_section vm.charts))
lib/presenter/routes.ml
@@ -14,7 +14,6 @@
14
14
let%path finish_workout = "/workout/finish"
15
15
let%path cancel_workout = "/workout/cancel"
16
16
let%path logbook = "/logbook"
17
Removed:
let%path progress = "/progress"
18
17
let%path profile = "/profile"
19
18
let%path settings = "/settings"
20
19
let%path settings_theme = "/settings/theme"
lib/presenter/view_model.ml
@@ -189,20 +189,16 @@
189
189
is the source title verbatim, and an oxblood outline pill, not text
190
190
on the title, signals its imported status. It feeds progress per
191
191
exercise but is never a prescribed native record. *)
192
Removed:
at_unix : int; (** Start time in Unix seconds, for interleaving by date. *)
192
Added:
at_unix : int;
193
Added:
(** Start time in Unix seconds, for ordering and selection. *)
193
194
workout_name : string;
194
Removed:
date : string; (** Workout date, pre-formatted "YYYY-MM-DD" in UTC. *)
195
Removed:
duration : string;
196
Removed:
(** Finished elapsed time, pre-formatted for scanning. *)
197
195
duration_previous_seconds : int option;
198
196
(** The most recent earlier workout with the same prescription name,
199
197
strictly before this one, in seconds, when both finished. [None] for
200
198
a first performance of this workout or an unfinished one. *)
201
199
duration_seconds : int option;
202
Removed:
(** This workout's own elapsed seconds, when finished. Carried alongside
203
Removed:
the pre-formatted {!duration} so the presenter can derive a signed
204
Removed:
delta against {!duration_previous_seconds} without re-parsing
205
Removed:
display text. *)
200
Added:
(** This workout's own elapsed seconds, when finished. Carried with the
201
Added:
previous duration so the presenter can derive a signed delta. *)
206
202
changes : Logbook_change.t list;
207
203
(** Per-exercise comparisons for a native workout, in performance order,
208
204
each carrying its own verdict. Empty for an import. *)
lib/web/assets/hito.css
@@ -299,6 +299,34 @@
299
299
text-decoration: none;
300
300
}
301
301
.page-heading-link:hover { text-decoration: underline; }
302
Added:
303
Added:
/* The Logbook views use one endpoint with two full-width link tabs. The active
304
Added:
tab uses the same blood treatment as the application navigation. */
305
Added:
.logbook-tabs {
306
Added:
display: grid;
307
Added:
grid-template-columns: repeat(2, minmax(0, 1fr));
308
Added:
width: 100%;
309
Added:
margin: 0 0 1.25rem;
310
Added:
border: 1px solid var(--rule-strong);
311
Added:
border-radius: var(--radius);
312
Added:
overflow: hidden;
313
Added:
}
314
Added:
.logbook-tab {
315
Added:
display: flex;
316
Added:
min-height: 2.75rem;
317
Added:
align-items: center;
318
Added:
justify-content: center;
319
Added:
padding: 0.55rem 0.9rem;
320
Added:
color: var(--ink);
321
Added:
background: transparent;
322
Added:
text-decoration: none;
323
Added:
}
324
Added:
.logbook-tab + .logbook-tab { border-left: 1px solid var(--rule-strong); }
325
Added:
.logbook-tab:hover,
326
Added:
.logbook-tab[aria-current="page"] {
327
Added:
color: var(--on-primary);
328
Added:
background: var(--blood);
329
Added:
}
302
330
.next-workout-exercise { flex: 1 1 auto; }
303
331
/* The current best weight × reps to beat: an observed fact, monospaced and
304
332
quiet, aligned to the right of each exercise. */
lib/web/handlers_logbook.ml
@@ -216,15 +216,6 @@
216
216
~current:c.Progress.current;
217
217
})
218
218
in
219
Removed:
let duration_seconds =
220
Removed:
Option.map Recovery.duration_to_seconds
221
Removed:
(Evidence.Workout.duration workout)
222
Removed:
in
223
Removed:
let duration =
224
Removed:
match duration_seconds with
225
Removed:
| None -> "—"
226
Removed:
| Some seconds -> Printf.sprintf "%d min" (seconds / 60)
227
Removed:
in
228
219
let duration_previous_seconds =
229
220
previous_same_workout_duration log workout
230
221
in
@@ -234,10 +225,10 @@
234
225
at_unix = started;
235
226
workout_name =
236
227
Prescription.Workout.name (Evidence.Workout.prescription workout);
237
Removed:
date = utc_date started;
238
Removed:
duration;
239
228
duration_previous_seconds;
240
Removed:
duration_seconds;
229
Added:
duration_seconds =
230
Added:
Option.map Recovery.duration_to_seconds
231
Added:
(Evidence.Workout.duration workout);
241
232
changes;
242
233
imported_efforts = [];
243
234
}
@@ -306,8 +297,6 @@
306
297
imported = true;
307
298
at_unix = started;
308
299
workout_name = record.Repository.title;
309
Removed:
date = utc_date started;
310
Removed:
duration = Printf.sprintf "%d min" ((ended - started) / 60);
311
300
duration_previous_seconds = None;
312
301
duration_seconds = Some (ended - started);
313
302
changes = [];
@@ -317,6 +306,16 @@
317
306
318
307
let index t trainee request =
319
308
let viewer = viewer_of trainee in
309
Added:
let query_int name default =
310
Added:
match Option.bind (Dream.query request name) int_of_string_opt with
311
Added:
| Some v -> v
312
Added:
| None -> default
313
Added:
in
314
Added:
let tab =
315
Added:
match Dream.query request "tab" with
316
Added:
| Some "progress" -> "progress"
317
Added:
| _ -> "calendar"
318
Added:
in
320
319
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
321
320
Service.history t.service trainee.Trainee.id >>= fun records ->
322
321
Service.workout_log t.service trainee.Trainee.id >>= fun log ->
@@ -334,11 +333,6 @@
334
333
let now = Recovery.timestamp_to_unix_seconds (t.now ()) in
335
334
let now_tm = Unix.gmtime (float_of_int now) in
336
335
let clamp lo hi v = max lo (min hi v) in
337
Removed:
let query_int name default =
338
Removed:
match Option.bind (Dream.query request name) int_of_string_opt with
339
Removed:
| Some v -> v
340
Removed:
| None -> default
341
Removed:
in
342
336
let year = query_int "year" (now_tm.Unix.tm_year + 1900) in
343
337
let month = clamp 1 12 (query_int "month" (now_tm.Unix.tm_mon + 1)) in
344
338
let selected_day_number =
@@ -413,6 +407,37 @@
413
407
calendar_view ~now ~year ~month ~selected_day:selected_day_number ~planned
414
408
all_records
415
409
in
410
Added:
let progress_charts_lwt =
411
Added:
if String.equal tab "progress" then
412
Added:
let point_of o : View_model.Progress_page.point =
413
Added:
let effort = Evidence.Observation.effort o in
414
Added:
let at =
415
Added:
Recovery.timestamp_to_unix_seconds
416
Added:
(Evidence.Observation.performed_at o)
417
Added:
in
418
Added:
{
419
Added:
date = utc_date at;
420
Added:
at_unix = at;
421
Added:
weight = Evidence.Stimulus.Effort.load effort;
422
Added:
reps = Evidence.Stimulus.Effort.reps effort;
423
Added:
}
424
Added:
in
425
Added:
Lwt_list.filter_map_s
426
Added:
(fun exercise ->
427
Added:
Service.exercise_history t.service trainee.Trainee.id exercise
428
Added:
>|= fun history ->
429
Added:
if List.length history >= 2 then
430
Added:
Some
431
Added:
{
432
Added:
View_model.Progress_page.exercise_name =
433
Added:
Exercise.name exercise;
434
Added:
points = List.map point_of history;
435
Added:
}
436
Added:
else None)
437
Added:
Exercise.catalog
438
Added:
else Lwt.return []
439
Added:
in
440
Added:
progress_charts_lwt >>= fun progress_charts ->
416
441
html
417
442
(Pages.logbook request
418
443
?workout_started:(workout_started in_progress)
@@ -421,7 +446,7 @@
421
446
(match Dream.query request "prompt" with
422
447
| Some "feedback" -> true
423
448
| _ -> false)
424
Removed:
~feedback_open ?delete_record ())
449
Added:
~feedback_open ?delete_record ~tab ~progress_charts ())
425
450
426
451
(* One post saves the whole report. The app stores nothing before Submit, so
427
452
there is no partial state to keep or discard. An empty submission is a
@@ -494,48 +519,8 @@
494
519
redirect_with_flash_to request Routes.logbook
495
520
"That workout log is no longer available.")
496
521
497
Removed:
(* The progress page. One dual-axis line chart per exercise that has at least
498
Removed:
two observations in the merged native and imported history. Exercises with
499
Removed:
too little to draw a line are omitted. There is no search. The page shows
500
Removed:
every chartable exercise. *)
501
Removed:
let progress t trainee request =
502
Removed:
let viewer = viewer_of trainee in
503
Removed:
let point_of o : View_model.Progress_page.point =
504
Removed:
let effort = Evidence.Observation.effort o in
505
Removed:
let at =
506
Removed:
Recovery.timestamp_to_unix_seconds (Evidence.Observation.performed_at o)
507
Removed:
in
508
Removed:
{
509
Removed:
date = utc_date at;
510
Removed:
at_unix = at;
511
Removed:
weight = Evidence.Stimulus.Effort.load effort;
512
Removed:
reps = Evidence.Stimulus.Effort.reps effort;
513
Removed:
}
514
Removed:
in
515
Removed:
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
516
Removed:
let started = workout_started in_progress in
517
Removed:
(* One history fetch per catalog exercise, kept only when it can draw. *)
518
Removed:
Lwt_list.filter_map_s
519
Removed:
(fun exercise ->
520
Removed:
Service.exercise_history t.service trainee.Trainee.id exercise
521
Removed:
>|= fun history ->
522
Removed:
if List.length history >= 2 then
523
Removed:
Some
524
Removed:
{
525
Removed:
View_model.Progress_page.exercise_name = Exercise.name exercise;
526
Removed:
points = List.map point_of history;
527
Removed:
}
528
Removed:
else None)
529
Removed:
Exercise.catalog
530
Removed:
>>= fun charts ->
531
Removed:
html
532
Removed:
(Pages.progress request ?workout_started:started ~viewer
533
Removed:
{ View_model.Progress_page.charts })
534
Removed:
535
522
let routes t =
536
523
[
537
Removed:
Dream_html.get Routes.progress (fun request ->
538
Removed:
authenticated t request (fun trainee -> progress t trainee request));
539
524
Dream_html.get Routes.logbook (fun request ->
540
525
authenticated t request (fun trainee -> index t trainee request));
541
526
Dream_html.post Routes.feedback (fun request ->
test/web/logbook.ml
@@ -12,14 +12,28 @@
12
12
let _ = sign_in_new c in
13
13
let page = body (get c "/logbook") in
14
14
Alcotest.(check bool)
15
Removed:
"Logbook keeps progress beside its title" true
16
Removed:
(Option.is_some (index_from ~needle:"View progress" page 0));
17
Removed:
List.iter
18
Removed:
(fun path ->
19
Removed:
Alcotest.(check int)
20
Removed:
(path ^ " is available") 200
21
Removed:
(status (get c path)))
22
Removed:
[ "/logbook"; "/progress"; "/logbook?feedback=start" ] );
15
Added:
"Logbook has Calendar tab" true
16
Added:
(Option.is_some
17
Added:
(index_from ~needle:"href=\"/logbook?tab=calendar\"" page 0));
18
Added:
Alcotest.(check bool)
19
Added:
"Logbook has Progress tab" true
20
Added:
(Option.is_some
21
Added:
(index_from ~needle:"href=\"/logbook?tab=progress\"" page 0));
22
Added:
let progress_page = body (get c "/logbook?tab=progress") in
23
Added:
Alcotest.(check bool)
24
Added:
"Progress tab is active" true
25
Added:
(Option.is_some
26
Added:
(index_from ~needle:"aria-current=\"page\"" progress_page 0));
27
Added:
Alcotest.(check bool)
28
Added:
"Progress tab does not show the calendar" true
29
Added:
(Option.is_none
30
Added:
(index_from ~needle:"calendar-grid" progress_page 0));
31
Added:
Alcotest.(check int)
32
Added:
"the old progress endpoint is gone" 404
33
Added:
(status (get c "/progress"));
34
Added:
Alcotest.(check int)
35
Added:
"the feedback modal stays available" 200
36
Added:
(status (get c "/logbook?feedback=start")) );
23
37
( "the calendar marks the next routine workout after recovery",
24
38
`Quick,
25
39
fun () ->
@@ -642,7 +656,7 @@
642
656
in
643
657
Alcotest.(check int)
644
658
"progress is available after imported history" 200
645
Removed:
(status (get c "/progress")) );
659
Added:
(status (get c "/logbook?tab=progress")) );
646
660
( "the progress page reports too little history to chart",
647
661
`Quick,
648
662
fun () ->
@@ -697,7 +711,7 @@
697
711
in
698
712
Alcotest.(check int)
699
713
"progress remains available with one observation" 200
700
Removed:
(status (get c "/progress")) );
714
Added:
(status (get c "/logbook?tab=progress")) );
701
715
( "the logbook supports browsing recorded and empty months",
702
716
`Quick,
703
717
fun () ->