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.

Commit
4e8205e0d92ea592e8de218641f5c5dd2d6e8dff
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/dune
index bd0155e4..5a26b945 100644..100644
@@ -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
index 12a82826..cb6ee497 100644..100644
@@ -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
index 4c80bbae..f5324a5c 100644..100644
@@ -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
index 640ecf1d..9d800788 100644..100644
@@ -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
index cfc8f130..00000000 100644..000000
@@ -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
index 5f0ba4b1..faf1c3a2 100644..100644
@@ -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
index c48b3a87..06683497 100644..100644
@@ -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
index 24bb3564..8344cab6 100644..100644
@@ -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
index 8f7324fc..a86f532c 100644..100644
@@ -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
index f4d8e9d7..2c4eaadd 100644..100644
@@ -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 () ->