feat unify rows, per-exercise progress in the menu, progress page

Logbook rows now share one shape at every width: monospaced date, sans workout name with an oxblood-outline Imported pill, monospaced duration, then a three-dot menu. The dedicated Progress column is gone. Progress moves into the three-dot menu, colour-coded per exercise: progress oxblood, regress green, stagnation brass. A native menu shows the per-exercise comparison then Edit and Delete; an imported menu lists each imported effort and its progress since last performed. Add a /progress page, reached from a button group under the logbook heading. A no-JS exercise search submits a name and the page returns that exercise's dual-axis line chart -- weight on the left axis, reps on the right -- built as server-rendered SVG (new Svg_chart module, no charting dependency) from the merged native and imported history (new Service.exercise_history and Progress.previous_observation). Fewer than two performances reports too little history to chart. Expose Exercise.catalog for the search.

Commit
982cb3c614f3c82dfc956d23f6bab38917b65bab
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/core/exercise.mli
index 16f61f30..c6c04442 100644..100644
@@ -77,6 +77,9 @@
77 77 val find : key -> t option
78 78 (** Looks up an exercise by structured identity. *)
79 79
80 Added: val catalog : t list
81 Added: (** Every curated exercise, in the order the Ideal Routine introduces them. *)
82 Added:
80 83 val find_id : string -> t option
81 84 (** Looks up an exercise by stable serialized identifier. *)
82 85
lib/presenter/dune
index ed5da110..bd0155e4 100644..100644
@@ -12,6 +12,8 @@
12 12 pages_routine
13 13 pages_workout
14 14 pages_logbook
15 Added: pages_progress
16 Added: svg_chart
15 17 pages_app_feedback
16 18 pages_profile
17 19 pages_settings
lib/presenter/pages.ml
index cb6ee497..12a82826 100644..100644
@@ -15,6 +15,7 @@
15 15 let workout = Pages_workout.workout
16 16 let feedback_factors = Pages_logbook.feedback_factors
17 17 let logbook = Pages_logbook.logbook
18 Added: let progress = Pages_progress.progress
18 19 let app_feedback = Pages_app_feedback.app_feedback
19 20 let profile = Pages_profile.profile
20 21 let settings = Pages_settings.settings
lib/presenter/pages.mli
index 2ca1fcba..28bd1a9e 100644..100644
@@ -70,6 +70,16 @@
70 70 [suggest_feedback] shows a prompt inviting feedback, set after a workout
71 71 finishes. [feedback_open] renders the modal open for no-script clients. *)
72 72
73 Added: val progress :
74 Added: Dream.request ->
75 Added: ?logging:bool ->
76 Added: viewer:View_model.Viewer.t ->
77 Added: View_model.Progress_page.t ->
78 Added: page
79 Added: (** The per-exercise progress page: a no-JS exercise search that reloads with
80 Added: the chosen exercise's dual-axis line chart (weight on the left, reps on the
81 Added: right), from the merged native and imported history. *)
82 Added:
73 83 val profile :
74 84 Dream.request ->
75 85 ?logging:bool ->
lib/presenter/pages_logbook.ml
index 8db841e6..a01031e0 100644..100644
@@ -152,44 +152,36 @@
152 152 (if abs reps = 1 then "" else "s");
153 153 ]
154 154 in
155 Removed: String.concat " · " pieces
155 Added: if pieces = [] then "no change" else String.concat " · " pieces
156 156
157 Removed: let progress_detail_row (entry : View_model.Logbook_entry.t) =
158 Removed: tag "tr"
159 Removed: [ class_ "logbook-detail-row" ]
157 Added: (* The colour class for a per-exercise verdict: progress oxblood, regress green,
158 Added: stagnation brass. A first performance (no verdict) is neutral. *)
159 Added: let verdict_class = function
160 Added: | Some View_model.Progressed -> "verdict-progress"
161 Added: | Some View_model.Regressed -> "verdict-regress"
162 Added: | Some View_model.Stagnant -> "verdict-stagnant"
163 Added: | None -> "verdict-none"
164 Added:
165 Added: (* One exercise line in a row menu: name, the efforts, and a colour-coded
166 Added: delta. Shared by native and imported menus. *)
167 Added: let exercise_line ~name ~efforts ~verdict ~delta =
168 Added: tag "li"
169 Added: [ Dream_html.string_attr "class" "%s" (verdict_class verdict) ]
160 170 [
161 Removed: tag "td"
162 Removed: [ Dream_html.string_attr "colspan" "5" ]
163 Removed: [
164 Removed: tag "section"
165 Removed: [ class_ "logbook-progress-detail" ]
166 Removed: [
167 Removed: tag "h2" [] [ txt "Progress since the prior workout" ];
168 Removed: tag "ul"
169 Removed: [ class_ "logbook-change-list" ]
170 Removed: (List.map
171 Removed: (fun (change : View_model.Logbook_change.t) ->
172 Removed: tag "li" []
173 Removed: [
174 Removed: tag "strong" [] [ txt "%s" change.exercise_name ];
175 Removed: tag "span"
176 Removed: [ class_ "change-efforts" ]
177 Removed: [
178 Removed: txt "%g kg × %d → %g kg × %d" change.previous_load
179 Removed: change.previous_reps change.current_load
180 Removed: change.current_reps;
181 Removed: ];
182 Removed: tag "span"
183 Removed: [ class_ "change-delta" ]
184 Removed: [ txt "%s" (change_delta change) ];
185 Removed: ])
186 Removed: entry.changes);
187 Removed: ];
188 Removed: ];
171 Added: tag "strong" [] [ txt "%s" name ];
172 Added: tag "span" [ class_ "change-efforts" ] [ txt "%s" efforts ];
173 Added: tag "span" [ class_ "change-delta" ] [ txt "%s" delta ];
189 174 ]
190 175
176 Added: let native_change_line (change : View_model.Logbook_change.t) =
177 Added: exercise_line ~name:change.exercise_name
178 Added: ~efforts:
179 Added: (Printf.sprintf "%g kg × %d → %g kg × %d" change.previous_load
180 Added: change.previous_reps change.current_load change.current_reps)
181 Added: ~verdict:(Some change.verdict) ~delta:(change_delta change)
182 Added:
191 183 (* The progress since an imported effort was last performed, as a compact
192 Removed: delta. Empty string when there is no prior performance. *)
184 Added: delta. *)
193 185 let imported_delta (e : View_model.Imported_effort.t) =
194 186 match (e.previous_load, e.previous_reps) with
195 187 | Some pl, Some pr ->
@@ -208,52 +200,100 @@
208 200 if pieces = [] then "no change" else String.concat " · " pieces
209 201 | _ -> "first performance"
210 202
211 Removed: (* The three-dot menu for an imported workout. Its drawer lists each imported
212 Removed: exercise with its effort and the progress since that exercise was last
213 Removed: performed. This is where an import's detail lives -- there is no separate
214 Removed: expandable detail row, and no tag on the title: the menu itself signals the
215 Removed: workout was imported. *)
216 Removed: let imported_menu (entry : View_model.Logbook_entry.t) =
203 Added: let imported_effort_line (e : View_model.Imported_effort.t) =
204 Added: exercise_line ~name:e.exercise_name
205 Added: ~efforts:(Printf.sprintf "%g kg × %d" e.load e.reps)
206 Added: ~verdict:e.verdict ~delta:(imported_delta e)
207 Added:
208 Added: (* The three-dot menu shared shape: a summary button opening a drawer. *)
209 Added: let row_menu ~aria_summary ~aria_drawer ~extra_class contents =
217 210 tag "details"
218 211 [ class_ "logbook-row-menu" ]
219 212 [
220 213 tag "summary"
221 Removed: [
222 Removed: Dream_html.string_attr "aria-label" "Imported exercises for %s on %s"
223 Removed: entry.workout_name entry.date;
224 Removed: ]
214 Added: [ Dream_html.string_attr "aria-label" "%s" aria_summary ]
225 215 [ txt "⋮" ];
226 216 tag "div"
227 217 [
228 Removed: class_ "logbook-row-menu-drawer logbook-import-drawer";
218 Added: Dream_html.string_attr "class" "logbook-row-menu-drawer %s"
219 Added: extra_class;
229 220 Dream_html.string_attr "role" "menu";
230 Removed: Dream_html.string_attr "aria-label" "Imported exercises";
221 Added: Dream_html.string_attr "aria-label" "%s" aria_drawer;
231 222 ]
232 Removed: [
233 Removed: tag "ul"
234 Removed: [ class_ "logbook-change-list" ]
235 Removed: (List.map
236 Removed: (fun (e : View_model.Imported_effort.t) ->
237 Removed: tag "li" []
238 Removed: [
239 Removed: tag "strong" [] [ txt "%s" e.exercise_name ];
240 Removed: tag "span"
241 Removed: [ class_ "change-efforts" ]
242 Removed: [ txt "%g kg × %d" e.load e.reps ];
243 Removed: tag "span"
244 Removed: [ class_ "change-delta" ]
245 Removed: [ txt "%s" (imported_delta e) ];
246 Removed: ])
247 Removed: entry.View_model.Logbook_entry.imported_efforts);
248 Removed: ];
223 Added: contents;
249 224 ]
250 225
226 Added: (* An imported workout's menu: the per-exercise efforts and progress since each
227 Added: was last performed. Read-only; no edit or delete. *)
228 Added: let imported_menu (entry : View_model.Logbook_entry.t) =
229 Added: row_menu
230 Added: ~aria_summary:
231 Added: (Printf.sprintf "Imported exercises for %s on %s" entry.workout_name
232 Added: entry.date)
233 Added: ~aria_drawer:"Imported exercises" ~extra_class:"logbook-import-drawer"
234 Added: [
235 Added: tag "ul"
236 Added: [ class_ "logbook-change-list" ]
237 Added: (List.map imported_effort_line
238 Added: entry.View_model.Logbook_entry.imported_efforts);
239 Added: ]
240 Added:
241 Added: (* A native workout's menu: a colour-coded per-exercise progress summary, then
242 Added: the Edit and Delete actions. *)
243 Added: let native_menu request (entry : View_model.Logbook_entry.t) =
244 Added: ignore request;
245 Added: let progress =
246 Added: if entry.changes = [] then
247 Added: [ tag "p" [ class_ "logbook-no-progress" ] [ txt "No prior comparison" ] ]
248 Added: else
249 Added: [
250 Added: tag "ul"
251 Added: [ class_ "logbook-change-list" ]
252 Added: (List.map native_change_line entry.changes);
253 Added: tag "hr" [ class_ "logbook-menu-rule" ] [];
254 Added: ]
255 Added: in
256 Added: row_menu
257 Added: ~aria_summary:
258 Added: (Printf.sprintf "Actions for %s on %s" entry.workout_name entry.date)
259 Added: ~aria_drawer:"Workout actions" ~extra_class:"logbook-import-drawer"
260 Added: (progress
261 Added: @ [
262 Added: tag "a"
263 Added: [
264 Added: href Routes.record entry.id;
265 Added: Dream_html.attr "data-hito-app-link";
266 Added: Dream_html.string_attr "role" "menuitem";
267 Added: ]
268 Added: [ txt "Edit" ];
269 Added: tag "a"
270 Added: [
271 Added: Dream_html.string_attr "href" "/logbook?delete=%s" entry.id;
272 Added: Dream_html.string_attr "role" "menuitem";
273 Added: class_ "danger";
274 Added: ]
275 Added: [ txt "Delete" ];
276 Added: ])
277 Added:
251 278 let logbook request ?(logging = false) ~viewer ?(feedback = [])
252 279 ?(suggest_feedback = false) ?(feedback_open = false) ?delete_record
253 280 ?expanded_record records =
281 Added: ignore expanded_record;
254 282 html_page ~viewer ~request ~active:"logbook" ~logging "Logbook"
255 283 [
256 284 tag "h1" [] [ txt "Logbook" ];
285 Added: (* Actions above the ledger. View progress opens the per-exercise chart. *)
286 Added: tag "div"
287 Added: [ class_ "button-group logbook-actions-bar" ]
288 Added: [
289 Added: tag "a"
290 Added: [
291 Added: href Routes.progress;
292 Added: class_ "button";
293 Added: Dream_html.attr "data-hito-app-link";
294 Added: ]
295 Added: [ txt "View progress" ];
296 Added: ];
257 297 (if records = [] then tag "p" [] [ txt "Nothing logged yet." ]
258 298 else
259 299 tag "table"
@@ -273,66 +313,18 @@
273 313 [ Dream_html.string_attr "scope" "col" ]
274 314 [ txt "Duration" ];
275 315 tag "th"
276 Removed: [ Dream_html.string_attr "scope" "col" ]
277 Removed: [ txt "Progress" ];
278 Removed: tag "th"
279 316 ([ class_ "logbook-actions-heading" ]
280 317 @ [ Dream_html.string_attr "scope" "col" ])
281 318 [ tag "span" [ class_ "sr-only" ] [ txt "Actions" ] ];
282 319 ];
283 320 ];
284 321 tag "tbody" []
285 Removed: (List.concat_map
322 Added: (List.map
286 323 (fun (entry : View_model.Logbook_entry.t) ->
287 Removed: let expanded =
288 Removed: Option.exists
289 Removed: (fun record ->
290 Removed: String.equal record.View_model.Logbook_entry.id
291 Removed: entry.id)
292 Removed: expanded_record
293 Removed: in
294 Removed: let expand_toggle ~label =
295 Removed: let attrs =
296 Removed: [
297 Removed: class_ "logbook-progress-toggle";
298 Removed: (* An app link, so the client swaps the content in
299 Removed: place rather than reloading the whole page. Without
300 Removed: script it stays an ordinary link. *)
301 Removed: Dream_html.attr "data-hito-app-link";
302 Removed: Dream_html.string_attr "aria-expanded" "%b" expanded;
303 Removed: ]
304 Removed: @
305 Removed: if expanded then [ href Routes.logbook ]
306 Removed: else
307 Removed: [
308 Removed: Dream_html.string_attr "href" "/logbook?expand=%s"
309 Removed: entry.id;
310 Removed: ]
311 Removed: in
312 Removed: tag "a" attrs
313 Removed: [ txt "%s %s" label (if expanded then "▴" else "▾") ]
314 Removed: in
315 Removed: let progress =
316 Removed: if entry.View_model.Logbook_entry.imported then txt ""
317 Removed: else if entry.compared_exercises = 0 then
318 Removed: tag "span"
319 Removed: [ class_ "logbook-no-progress" ]
320 Removed: [ txt "No prior comparison" ]
321 Removed: else if entry.changes = [] then
322 Removed: tag "span"
323 Removed: [ class_ "logbook-no-progress" ]
324 Removed: [ txt "No progress" ]
325 Removed: else
326 Removed: expand_toggle
327 Removed: ~label:
328 Removed: (Printf.sprintf "%d/%d progressed"
329 Removed: entry.progressed_exercises
330 Removed: entry.compared_exercises)
331 Removed: in
332 Removed: (* The title shows verbatim. An import is signalled by a
333 Removed: primary-colour "Imported" pill beside it, and its three-
334 Removed: dot menu carries its exercises and progress. A native
335 Removed: record's menu carries Edit and Delete. *)
324 Added: (* The title shows verbatim; an oxblood outline pill marks an
325 Added: import. The three-dot menu carries the colour-coded
326 Added: per-exercise progress, plus Edit/Delete for a native
327 Added: record. *)
336 328 let name_cell =
337 329 tag "td" []
338 330 (txt "%s" entry.workout_name
@@ -345,66 +337,19 @@
345 337 ]
346 338 else []))
347 339 in
348 Removed: let actions =
349 Removed: if entry.imported then [ imported_menu entry ]
350 Removed: else
351 Removed: [
352 Removed: tag "details"
353 Removed: [ class_ "logbook-row-menu" ]
354 Removed: [
355 Removed: tag "summary"
356 Removed: [
357 Removed: Dream_html.string_attr "aria-label"
358 Removed: "Actions for %s on %s" entry.workout_name
359 Removed: entry.date;
360 Removed: ]
361 Removed: [ txt "⋮" ];
362 Removed: tag "div"
363 Removed: [
364 Removed: class_ "logbook-row-menu-drawer";
365 Removed: Dream_html.string_attr "role" "menu";
366 Removed: Dream_html.string_attr "aria-label"
367 Removed: "Workout actions";
368 Removed: ]
369 Removed: [
370 Removed: tag "a"
371 Removed: [
372 Removed: href Routes.record entry.id;
373 Removed: Dream_html.attr "data-hito-app-link";
374 Removed: Dream_html.string_attr "role" "menuitem";
375 Removed: ]
376 Removed: [ txt "Edit" ];
377 Removed: tag "a"
378 Removed: [
379 Removed: Dream_html.string_attr "href"
380 Removed: "/logbook?delete=%s" entry.id;
381 Removed: Dream_html.string_attr "role" "menuitem";
382 Removed: class_ "danger";
383 Removed: ]
384 Removed: [ txt "Delete" ];
385 Removed: ];
386 Removed: ];
387 Removed: ]
340 Added: let menu =
341 Added: if entry.imported then imported_menu entry
342 Added: else native_menu request entry
388 343 in
389 Removed: let row =
390 Removed: tag "tr"
391 Removed: (if expanded then [ class_ "logbook-row-expanded" ]
392 Removed: else [])
393 Removed: [
394 Removed: tag "td" [] [ txt "%s" entry.date ];
395 Removed: name_cell;
396 Removed: tag "td"
397 Removed: [ class_ "logbook-duration" ]
398 Removed: [ txt "%s" entry.duration ];
399 Removed: tag "td" [ class_ "logbook-progress" ] [ progress ];
400 Removed: tag "td" [ class_ "logbook-actions" ] actions;
401 Removed: ]
402 Removed: in
403 Removed: (* Only a native record has an expandable progress detail
404 Removed: row. An import's detail lives in its three-dot menu. *)
405 Removed: if expanded && not entry.imported then
406 Removed: [ row; progress_detail_row entry ]
407 Removed: else [ row ])
344 Added: tag "tr" []
345 Added: [
346 Added: tag "td" [] [ txt "%s" entry.date ];
347 Added: name_cell;
348 Added: tag "td"
349 Added: [ class_ "logbook-duration" ]
350 Added: [ txt "%s" entry.duration ];
351 Added: tag "td" [ class_ "logbook-actions" ] [ menu ];
352 Added: ])
408 353 records);
409 354 ]);
410 355 tag "section"
lib/presenter/pages_progress.ml
index 00000000..7bf4a9f9 000000..100644
@@ -0,0 +1,77 @@
1 Added: open Dream_html
2 Added: open Page_kit
3 Added: open Page_shell
4 Added:
5 Added: (* The exercise search: a native text input backed by a datalist, submitting a
6 Added: GET so the page reloads with the chosen exercise's chart. No script needed.
7 Added: The datalist maps a visible name to its id via a hidden convention: the
8 Added: input carries the id as its value once chosen from the list. To keep it
9 Added: no-JS, the list options use the exercise name and the form submits the name,
10 Added: which the handler resolves. *)
11 Added: let search_form ~(exercises : (string * string) list) ~selected_id =
12 Added: let selected_name =
13 Added: match selected_id with
14 Added: | None -> ""
15 Added: | Some id -> (
16 Added: match List.assoc_opt id exercises with Some name -> name | None -> "")
17 Added: in
18 Added: tag "form"
19 Added: [ action Routes.progress; class_ "progress-search" ]
20 Added: [
21 Added: tag "label" [ Dream_html.string_attr "for" "exercise" ] [ txt "Exercise" ];
22 Added: void "input"
23 Added: [
24 Added: id "exercise";
25 Added: name "exercise";
26 Added: Dream_html.string_attr "list" "exercise-options";
27 Added: Dream_html.string_attr "value" "%s" selected_name;
28 Added: Dream_html.string_attr "placeholder" "Search exercises…";
29 Added: Dream_html.string_attr "autocomplete" "off";
30 Added: ];
31 Added: tag "datalist"
32 Added: [ id "exercise-options" ]
33 Added: (List.map
34 Added: (fun (_, name) ->
35 Added: tag "option" [ Dream_html.string_attr "value" "%s" name ] [])
36 Added: exercises);
37 Added: tag "button" [ type_ "submit" ] [ txt "View" ];
38 Added: ]
39 Added:
40 Added: let legend () =
41 Added: tag "div"
42 Added: [ class_ "chart-legend" ]
43 Added: [
44 Added: tag "span"
45 Added: [ class_ "chart-legend-item weight" ]
46 Added: [ txt "Weight (kg), left axis" ];
47 Added: tag "span" [ class_ "chart-legend-item reps" ] [ txt "Reps, right axis" ];
48 Added: ]
49 Added:
50 Added: let progress request ?(logging = false) ~viewer
51 Added: (vm : View_model.Progress_page.t) =
52 Added: html_page ~viewer ~request ~active:"logbook" ~logging "Progress"
53 Added: [
54 Added: tag "h1" [] [ txt "Progress" ];
55 Added: search_form ~exercises:vm.exercises ~selected_id:vm.selected_id;
56 Added: (match vm.chart with
57 Added: | Some chart ->
58 Added: tag "section"
59 Added: [ class_ "progress-chart-section" ]
60 Added: [
61 Added: tag "h2" [] [ txt "%s" chart.exercise_name ];
62 Added: legend ();
63 Added: Svg_chart.render chart.points;
64 Added: ]
65 Added: | None ->
66 Added: if vm.insufficient then
67 Added: tag "p"
68 Added: [ class_ "eyebrow" ]
69 Added: [
70 Added: txt
71 Added: "Not enough history to chart yet — log or import at least \
72 Added: two performances of this exercise.";
73 Added: ]
74 Added: else
75 Added: tag "p" []
76 Added: [ txt "Search for an exercise to see its progress chart." ]);
77 Added: ]
lib/presenter/routes.ml
index f4477c53..5f04fd99 100644..100644
@@ -14,6 +14,7 @@
14 14 let%path finish_workout = "/workout/finish"
15 15 let%path cancel_workout = "/workout/cancel"
16 16 let%path logbook = "/logbook"
17 Added: let%path progress = "/progress"
17 18 let%path profile = "/profile"
18 19 let%path settings = "/settings"
19 20 let%path settings_theme = "/settings/theme"
lib/presenter/svg_chart.ml
index 00000000..9839a6f3 000000..100644
@@ -0,0 +1,160 @@
1 Added: (* A server-rendered SVG line chart with two data series on independent axes:
2 Added: weight on the left, reps on the right. No client script and no charting
3 Added: dependency -- the data is small and the shape is simple, so static SVG built
4 Added: from dream-html nodes fits the app's no-JS-first design and stays testable.
5 Added:
6 Added: SVG elements are built with [Dream_html.std_tag]; SVG in HTML5 needs no
7 Added: explicit namespace. Coordinates are plain floats in a fixed viewBox, so the
8 Added: chart scales to its container. *)
9 Added:
10 Added: open Dream_html
11 Added:
12 Added: let svg = std_tag "svg"
13 Added: let g = std_tag "g"
14 Added: let polyline = std_tag "polyline"
15 Added: let line = std_tag "line"
16 Added: let circle = std_tag "circle"
17 Added: let text = std_tag "text"
18 Added: let sattr = string_attr
19 Added: let fattr = float_attr
20 Added:
21 Added: (* Fixed drawing surface. The viewBox scales to the container width via CSS. *)
22 Added: let width = 640.
23 Added: let height = 320.
24 Added: let pad_left = 48.
25 Added: let pad_right = 48.
26 Added: let pad_top = 24.
27 Added: let pad_bottom = 36.
28 Added: let plot_w = width -. pad_left -. pad_right
29 Added: let plot_h = height -. pad_top -. pad_bottom
30 Added:
31 Added: (* Map a value in [lo, hi] to a y pixel, inverted so larger is higher. A flat
32 Added: series (lo = hi) pins to the vertical middle rather than dividing by zero. *)
33 Added: let scale_y ~lo ~hi v =
34 Added: if Float.equal lo hi then pad_top +. (plot_h /. 2.)
35 Added: else pad_top +. (plot_h *. (1. -. ((v -. lo) /. (hi -. lo))))
36 Added:
37 Added: (* Evenly spaced x for [n] points across the plot; a single point sits centre. *)
38 Added: let scale_x ~n i =
39 Added: if n <= 1 then pad_left +. (plot_w /. 2.)
40 Added: else pad_left +. (plot_w *. float_of_int i /. float_of_int (n - 1))
41 Added:
42 Added: let bounds values =
43 Added: match values with
44 Added: | [] -> (0., 1.)
45 Added: | first :: _ ->
46 Added: let lo = List.fold_left Float.min first values in
47 Added: let hi = List.fold_left Float.max first values in
48 Added: (* Pad a flat or tiny range so the line is not glued to an edge. *)
49 Added: if Float.equal lo hi then (lo -. 1., hi +. 1.) else (lo, hi)
50 Added:
51 Added: let points_attr coords =
52 Added: coords
53 Added: |> List.map (fun (x, y) -> Printf.sprintf "%.1f,%.1f" x y)
54 Added: |> String.concat " "
55 Added:
56 Added: let series ~css_class coords =
57 Added: let markers =
58 Added: List.map
59 Added: (fun (x, y) ->
60 Added: circle
61 Added: [
62 Added: sattr "class" "%s" css_class;
63 Added: fattr "cx" x;
64 Added: fattr "cy" y;
65 Added: fattr "r" 3.;
66 Added: ]
67 Added: [])
68 Added: coords
69 Added: in
70 Added: g []
71 Added: (polyline
72 Added: [
73 Added: sattr "class" "%s" css_class;
74 Added: sattr "fill" "none";
75 Added: sattr "points" "%s" (points_attr coords);
76 Added: ]
77 Added: []
78 Added: :: markers)
79 Added:
80 Added: let axis_line ~x1 ~y1 ~x2 ~y2 =
81 Added: line
82 Added: [
83 Added: sattr "class" "chart-axis";
84 Added: fattr "x1" x1;
85 Added: fattr "y1" y1;
86 Added: fattr "x2" x2;
87 Added: fattr "y2" y2;
88 Added: ]
89 Added: []
90 Added:
91 Added: let axis_label ~x ~y ~anchor ~css_class s =
92 Added: text
93 Added: [
94 Added: sattr "class" "%s" css_class;
95 Added: fattr "x" x;
96 Added: fattr "y" y;
97 Added: sattr "text-anchor" "%s" anchor;
98 Added: ]
99 Added: [ txt "%s" s ]
100 Added:
101 Added: (* Render the chart for a series of (weight, reps) points in time order. The
102 Added: caller guarantees at least two points. *)
103 Added: let render (points : View_model.Progress_page.point list) =
104 Added: let n = List.length points in
105 Added: let weights =
106 Added: List.map (fun (p : View_model.Progress_page.point) -> p.weight) points
107 Added: in
108 Added: let reps =
109 Added: List.map
110 Added: (fun (p : View_model.Progress_page.point) -> float_of_int p.reps)
111 Added: points
112 Added: in
113 Added: let w_lo, w_hi = bounds weights in
114 Added: let r_lo, r_hi = bounds reps in
115 Added: let weight_coords =
116 Added: List.mapi
117 Added: (fun i (p : View_model.Progress_page.point) ->
118 Added: (scale_x ~n i, scale_y ~lo:w_lo ~hi:w_hi p.weight))
119 Added: points
120 Added: in
121 Added: let rep_coords =
122 Added: List.mapi
123 Added: (fun i (p : View_model.Progress_page.point) ->
124 Added: (scale_x ~n i, scale_y ~lo:r_lo ~hi:r_hi (float_of_int p.reps)))
125 Added: points
126 Added: in
127 Added: svg
128 Added: [
129 Added: sattr "class" "progress-chart";
130 Added: sattr "viewBox" "0 0 %g %g" width height;
131 Added: sattr "role" "img";
132 Added: sattr "aria-label"
133 Added: "Line chart of weight and reps over time for the selected exercise";
134 Added: sattr "preserveAspectRatio" "xMidYMid meet";
135 Added: ]
136 Added: [
137 Added: (* Axes: left (weight), right (reps), and a baseline. *)
138 Added: axis_line ~x1:pad_left ~y1:pad_top ~x2:pad_left ~y2:(pad_top +. plot_h);
139 Added: axis_line ~x1:(pad_left +. plot_w) ~y1:pad_top ~x2:(pad_left +. plot_w)
140 Added: ~y2:(pad_top +. plot_h);
141 Added: axis_line ~x1:pad_left ~y1:(pad_top +. plot_h) ~x2:(pad_left +. plot_w)
142 Added: ~y2:(pad_top +. plot_h);
143 Added: (* Left axis bounds (weight, kg). *)
144 Added: axis_label ~x:(pad_left -. 6.) ~y:(pad_top +. 4.) ~anchor:"end"
145 Added: ~css_class:"chart-tick weight" (Printf.sprintf "%g" w_hi);
146 Added: axis_label ~x:(pad_left -. 6.) ~y:(pad_top +. plot_h) ~anchor:"end"
147 Added: ~css_class:"chart-tick weight" (Printf.sprintf "%g" w_lo);
148 Added: (* Right axis bounds (reps). *)
149 Added: axis_label
150 Added: ~x:(pad_left +. plot_w +. 6.)
151 Added: ~y:(pad_top +. 4.) ~anchor:"start" ~css_class:"chart-tick reps"
152 Added: (Printf.sprintf "%g" r_hi);
153 Added: axis_label
154 Added: ~x:(pad_left +. plot_w +. 6.)
155 Added: ~y:(pad_top +. plot_h) ~anchor:"start" ~css_class:"chart-tick reps"
156 Added: (Printf.sprintf "%g" r_lo);
157 Added: (* The two series. *)
158 Added: series ~css_class:"chart-series weight" weight_coords;
159 Added: series ~css_class:"chart-series reps" rep_coords;
160 Added: ]
lib/presenter/view_model.ml
index 281042b7..5f604550 100644..100644
@@ -118,6 +118,10 @@
118 118 form, since its option set is fixed UI vocabulary, not domain data. *)
119 119 end
120 120
121 Added: (** A per-exercise progress verdict, for colour coding. Progress is oxblood,
122 Added: regress green, stagnation brass. *)
123 Added: type verdict = Progressed | Regressed | Stagnant
124 Added:
121 125 module Logbook_change = struct
122 126 type t = {
123 127 exercise_name : string;
@@ -125,9 +129,11 @@
125 129 previous_reps : int;
126 130 current_load : float;
127 131 current_reps : int;
132 Added: verdict : verdict; (** This exercise's outcome versus its prior baseline. *)
128 133 }
129 Removed: (** Exact evidence for one progressed exercise. The presenter derives display
130 Removed: deltas from these plain values, never from a core effort. *)
134 Added: (** Exact evidence for one compared exercise. The presenter derives display
135 Added: deltas and the verdict colour from these plain values, never from a core
136 Added: effort. *)
131 137 end
132 138
133 139 module Imported_effort = struct
@@ -139,10 +145,13 @@
139 145 (** The same exercise's most recent prior performance, when one exists.
140 146 [None] for a first performance. *)
141 147 previous_reps : int option;
148 Added: verdict : verdict option;
149 Added: (** This effort's outcome versus its prior performance. [None] for a
150 Added: first performance, which has nothing to compare against. *)
142 151 }
143 152 (** One imported exercise effort with the progress since it was last
144 Removed: performed. The presenter derives the delta from the plain values; a first
145 Removed: performance carries no previous. *)
153 Added: performed. The presenter derives the delta and verdict colour from the
154 Added: plain values; a first performance carries no previous. *)
146 155 end
147 156
148 157 module Logbook_entry = struct
@@ -151,28 +160,56 @@
151 160 imported : bool;
152 161 (** True for a promoted third-party import. Shown read-only; its title
153 162 is the source title verbatim, and its imported status is signalled
154 Removed: by the row's three-dot menu, not by any tag on the title. It feeds
163 Added: by an oxblood outline pill, not by text on the title. It feeds
155 164 progress per exercise but is never a prescribed native record. *)
156 165 at_unix : int; (** Start time in Unix seconds, for interleaving by date. *)
157 166 workout_name : string;
158 167 date : string; (** Workout date, pre-formatted "YYYY-MM-DD" in UTC. *)
159 168 duration : string;
160 169 (** Finished elapsed time, pre-formatted for scanning. *)
161 Removed: progressed_exercises : int; (** Native only; zero for an import. *)
162 Removed: compared_exercises : int;
163 Removed: (** Exercises with a prior baseline. First performances do not count.
164 Removed: Native only; zero for an import. *)
165 170 changes : Logbook_change.t list;
166 Removed: (** Exact positive overload changes, in workout performance order.
167 Removed: Native only; empty for an import. *)
171 Added: (** Per-exercise comparisons for a native workout, in performance order,
172 Added: each carrying its own verdict. Empty for an import. *)
168 173 imported_efforts : Imported_effort.t list;
169 174 (** One per imported effort, in source order, each with the progress
170 175 since it was last performed. Empty for a native entry. *)
171 176 }
172 Removed: (** One logbook entry as the table shows it: a native prescribed workout or a
173 Removed: read-only imported workout, interleaved by date. Compact evidence for
174 Removed: scanning, plus the proof the expanded detail row needs. No Repository
175 Removed: record and no Evidence.Workout entity. *)
177 Added: (** One logbook entry as the row shows it: a native prescribed workout or a
178 Added: read-only imported workout, interleaved by date. Its cells are date, name
179 Added: (with an import pill), duration, and a three-dot menu that carries the
180 Added: per-exercise progress detail and the row actions. No Repository record and
181 Added: no Evidence.Workout entity. *)
182 Added: end
183 Added:
184 Added: module Progress_page = struct
185 Added: type point = {
186 Added: date : string; (** Pre-formatted "YYYY-MM-DD" in UTC. *)
187 Added: at_unix : int; (** For x-axis positioning. *)
188 Added: weight : float;
189 Added: reps : int;
190 Added: }
191 Added: (** One observation on the chart: its load and reps at a point in time. *)
192 Added:
193 Added: type chart = {
194 Added: exercise_name : string;
195 Added: points : point list; (** Oldest first. *)
196 Added: }
197 Added: (** The selected exercise's merged native and imported history, ready to plot
198 Added: as a dual-axis line chart: weight on the left axis, reps on the right. *)
199 Added:
200 Added: type t = {
201 Added: exercises : (string * string) list;
202 Added: (** Every catalog exercise as [(id, name)], for the search datalist. *)
203 Added: selected_id : string option; (** The chosen exercise id, if any. *)
204 Added: chart : chart option;
205 Added: (** [Some] once an exercise with at least two observations is selected.
206 Added: [None] before selection or with too little data to draw a line. *)
207 Added: insufficient : bool;
208 Added: (** True when an exercise was selected but has fewer than two
209 Added: observations, so no line can be drawn. *)
210 Added: }
211 Added: (** The progress page: a no-JS exercise search that reloads with the chosen
212 Added: exercise's chart. *)
176 213 end
177 214
178 215 module Import_warning = struct
lib/web/assets/hito.css
index 3c5e2ec1..87640a47 100644..100644
@@ -554,9 +554,9 @@
554 554 .logbook-table tbody { display: block; }
555 555 .logbook-table tbody > tr:not(.logbook-detail-row) {
556 556 display: grid;
557 Removed: grid-template-columns: auto auto minmax(0, 1fr) auto;
557 Added: grid-template-columns: auto minmax(0, 1fr) auto auto;
558 558 grid-template-areas:
559 Removed: "date duration workout actions"
559 Added: "date workout duration actions"
560 560 "progress progress progress progress";
561 561 gap: 0.25rem 0.5rem;
562 562 align-items: center;
@@ -572,10 +572,11 @@
572 572 border: none;
573 573 text-align: left;
574 574 }
575 Removed: /* One line per workout on mobile: monospaced date, duration, then the title,
576 Removed: then the row menu. A native record's progress toggle drops to a second line;
577 Removed: an import carries no progress cell here, so its row stays a single line and
578 Removed: its detail lives in the three-dot menu. */
575 Added: /* One line per workout, the same cell order and style at every width:
576 Added: monospaced date, sans workout name (with an import pill), monospaced
577 Added: duration, then the row menu. A native record's progress toggle drops to a
578 Added: second line on mobile; an import carries no progress cell, so its row stays a
579 Added: single line. */
579 580 .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(1) {
580 581 grid-area: date;
581 582 padding: 0.7rem 0 0.7rem 0.85rem;
@@ -587,6 +588,7 @@
587 588 grid-area: workout;
588 589 min-width: 0;
589 590 padding: 0.7rem 0;
591 Added: font-family: var(--sans);
590 592 font-weight: 400;
591 593 line-height: 1.25;
592 594 overflow: hidden;
@@ -596,6 +598,7 @@
596 598 .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(3) {
597 599 grid-area: duration;
598 600 padding: 0.7rem 0;
601 Added: font-family: var(--mono);
599 602 color: var(--muted-ink);
600 603 white-space: nowrap;
601 604 }
@@ -705,14 +708,15 @@
705 708 text-align: left;
706 709 }
707 710
708 Removed: /* A primary-colour pill beside the title marks a workout as imported. */
711 Added: /* An oxblood outline pill beside the title marks a workout as imported. */
709 712 .logbook-import-pill {
710 713 display: inline-block;
711 714 margin-left: 0.5rem;
712 Removed: padding: 0.1rem 0.5rem;
715 Added: padding: 0.05rem 0.5rem;
716 Added: border: 1px solid var(--oxblood);
713 717 border-radius: 999px;
714 Removed: color: var(--on-primary);
715 Removed: background: var(--oxblood);
718 Added: color: var(--oxblood-dark);
719 Added: background: transparent;
716 720 font: 700 var(--font-size-small)/1.4 var(--sans);
717 721 letter-spacing: 0.02em;
718 722 vertical-align: middle;
@@ -771,6 +775,60 @@
771 775 font: 700 var(--font-size-small)/1 var(--sans);
772 776 white-space: nowrap;
773 777 }
778 Added: /* Per-exercise progress verdict colours in the row menu: progress oxblood,
779 Added: regress green, stagnation brass. The colour lands on that line's delta. */
780 Added: .verdict-progress .change-delta { color: var(--oxblood-dark); }
781 Added: .verdict-regress .change-delta { color: var(--evergreen); }
782 Added: .verdict-stagnant .change-delta { color: var(--brass); }
783 Added: .verdict-none .change-delta { color: var(--muted-ink); }
784 Added:
785 Added: /* The action bar under the logbook heading. */
786 Added: .logbook-actions-bar { margin: 0.5rem 0 1rem; }
787 Added:
788 Added: /* The progress page: a no-JS exercise search and a dual-axis line chart. */
789 Added: .progress-search {
790 Added: display: flex;
791 Added: flex-wrap: wrap;
792 Added: gap: 0.5rem;
793 Added: align-items: end;
794 Added: margin: 1rem 0 1.5rem;
795 Added: }
796 Added: .progress-search label {
797 Added: flex: 0 0 100%;
798 Added: margin: 0;
799 Added: }
800 Added: .progress-search input { flex: 1 1 14rem; }
801 Added: .progress-search button { flex: 0 0 auto; }
802 Added:
803 Added: .progress-chart-section { margin-top: 1rem; }
804 Added: .chart-legend {
805 Added: display: flex;
806 Added: flex-wrap: wrap;
807 Added: gap: 1rem;
808 Added: margin: 0.5rem 0 1rem;
809 Added: font-size: var(--font-size-small);
810 Added: }
811 Added: .chart-legend-item { display: inline-flex; align-items: center; gap: 0.4rem; }
812 Added: .chart-legend-item::before {
813 Added: content: "";
814 Added: width: 1.1rem;
815 Added: height: 0.2rem;
816 Added: border-radius: 999px;
817 Added: }
818 Added: .chart-legend-item.weight::before { background: var(--oxblood); }
819 Added: .chart-legend-item.reps::before { background: var(--brass); }
820 Added:
821 Added: /* The chart scales to its container; the viewBox does the layout. */
822 Added: .progress-chart {
823 Added: width: 100%;
824 Added: height: auto;
825 Added: max-width: 40rem;
826 Added: }
827 Added: .chart-axis { stroke: var(--rule-strong); stroke-width: 1; }
828 Added: .chart-tick { font: 500 0.7rem var(--mono); fill: var(--muted-ink); }
829 Added: .chart-series { stroke-width: 2; }
830 Added: .chart-series.weight { stroke: var(--oxblood); fill: var(--oxblood); }
831 Added: .chart-series.reps { stroke: var(--brass); fill: var(--brass); }
774 832 @keyframes logbook-detail-open {
775 833 from { opacity: 0; transform: translateY(-0.25rem); }
776 834 to { opacity: 1; transform: none; }
lib/web/handlers_logbook.ml
index d39cf292..2152b3e4 100644..100644
@@ -37,26 +37,44 @@
37 37 Printf.sprintf "%04d-%02d-%02d" (tm.Unix.tm_year + 1900)
38 38 (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
39 39
40 Added: (* One exercise's outcome versus its prior effort: load leads, then reps,
41 Added: matching the Heavy Duty progress rule. Progress is an increase, regress a
42 Added: decrease, stagnation a flat repeat. *)
43 Added: let effort_verdict ~previous ~current : View_model.verdict =
44 Added: let load =
45 Added: Float.compare
46 Added: (Evidence.Stimulus.Effort.load current)
47 Added: (Evidence.Stimulus.Effort.load previous)
48 Added: in
49 Added: let reps =
50 Added: Int.compare
51 Added: (Evidence.Stimulus.Effort.reps current)
52 Added: (Evidence.Stimulus.Effort.reps previous)
53 Added: in
54 Added: if load > 0 || (load = 0 && reps > 0) then Progressed
55 Added: else if load < 0 || (load = 0 && reps < 0) then Regressed
56 Added: else Stagnant
57 Added:
40 58 let entry_view log (record : Repository.record) : View_model.Logbook_entry.t =
41 59 let workout = record.Repository.workout in
42 60 let started =
43 61 Recovery.timestamp_to_unix_seconds (Evidence.Workout.started_at workout)
44 62 in
45 Removed: let comparisons = Progress.workout_comparisons log workout in
63 Added: (* Every compared exercise, each with its own verdict — progress, regress,
64 Added: or stagnation — for the colour-coded per-exercise menu line. *)
46 65 let changes =
47 Removed: Progress.workout_changes log workout
48 Removed: |> List.map (fun comparison ->
66 Added: Progress.workout_comparisons log workout
67 Added: |> List.map (fun (c : Progress.workout_comparison) ->
49 68 View_model.Logbook_change.
50 69 {
51 Removed: exercise_name = Exercise.name comparison.Progress.exercise;
52 Removed: previous_load =
53 Removed: Evidence.Stimulus.Effort.load comparison.Progress.previous;
54 Removed: previous_reps =
55 Removed: Evidence.Stimulus.Effort.reps comparison.Progress.previous;
56 Removed: current_load =
57 Removed: Evidence.Stimulus.Effort.load comparison.Progress.current;
58 Removed: current_reps =
59 Removed: Evidence.Stimulus.Effort.reps comparison.Progress.current;
70 Added: exercise_name = Exercise.name c.Progress.exercise;
71 Added: previous_load = Evidence.Stimulus.Effort.load c.Progress.previous;
72 Added: previous_reps = Evidence.Stimulus.Effort.reps c.Progress.previous;
73 Added: current_load = Evidence.Stimulus.Effort.load c.Progress.current;
74 Added: current_reps = Evidence.Stimulus.Effort.reps c.Progress.current;
75 Added: verdict =
76 Added: effort_verdict ~previous:c.Progress.previous
77 Added: ~current:c.Progress.current;
60 78 })
61 79 in
62 80 let duration =
@@ -73,8 +91,6 @@
73 91 Prescription.Workout.name (Evidence.Workout.prescription workout);
74 92 date = date_of_unix started;
75 93 duration;
76 Removed: progressed_exercises = List.length changes;
77 Removed: compared_exercises = List.length comparisons;
78 94 changes;
79 95 imported_efforts = [];
80 96 }
@@ -87,20 +103,17 @@
87 103 View_model.Imported_effort.t =
88 104 let effort = Evidence.Observation.effort o in
89 105 let previous = Progress.previous_observation history o in
106 Added: let previous_effort = Option.map Evidence.Observation.effort previous in
90 107 {
91 108 exercise_name = Exercise.name (Evidence.Observation.exercise o);
92 109 load = Evidence.Stimulus.Effort.load effort;
93 110 reps = Evidence.Stimulus.Effort.reps effort;
94 Removed: previous_load =
111 Added: previous_load = Option.map Evidence.Stimulus.Effort.load previous_effort;
112 Added: previous_reps = Option.map Evidence.Stimulus.Effort.reps previous_effort;
113 Added: verdict =
95 114 Option.map
96 Removed: (fun p ->
97 Removed: Evidence.Stimulus.Effort.load (Evidence.Observation.effort p))
98 Removed: previous;
99 Removed: previous_reps =
100 Removed: Option.map
101 Removed: (fun p ->
102 Removed: Evidence.Stimulus.Effort.reps (Evidence.Observation.effort p))
103 Removed: previous;
115 Added: (fun previous -> effort_verdict ~previous ~current:effort)
116 Added: previous_effort;
104 117 }
105 118
106 119 (* Build one imported entry. Each imported effort carries the progress since
@@ -147,8 +160,6 @@
147 160 workout_name = record.Repository.title;
148 161 date = date_of_unix started;
149 162 duration = Printf.sprintf "%d min" ((ended - started) / 60);
150 Removed: progressed_exercises = 0;
151 Removed: compared_exercises = 0;
152 163 changes = [];
153 164 imported_efforts = efforts;
154 165 }
@@ -331,8 +342,87 @@
331 342 (Dream_html.path_attr Dream_html.HTML.href Routes.logbook)
332 343 "That workout log is no longer available.")
333 344
345 Added: (* The per-exercise progress page. The search submits a GET with the exercise
346 Added: name; resolve it to a catalog entry, then plot its merged native and
347 Added: imported history. A chart needs at least two observations to draw a line;
348 Added: fewer reports as insufficient. *)
349 Added: let progress t trainee request =
350 Added: let viewer =
351 Added: {
352 Added: View_model.Viewer.username =
353 Added: Trainee.username_to_string trainee.Trainee.username;
354 Added: }
355 Added: in
356 Added: let exercises =
357 Added: List.map
358 Added: (fun e -> ((Exercise.id e :> string), Exercise.name e))
359 Added: Exercise.catalog
360 Added: in
361 Added: (* The search field submits the exercise name; match it back to a catalog
362 Added: entry. *)
363 Added: let selected =
364 Added: match Dream.query request "exercise" with
365 Added: | None | Some "" -> None
366 Added: | Some query ->
367 Added: List.find_opt
368 Added: (fun e -> String.equal (Exercise.name e) (String.trim query))
369 Added: Exercise.catalog
370 Added: in
371 Added: Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
372 Added: let logging = Option.is_some in_progress in
373 Added: match selected with
374 Added: | None ->
375 Added: html
376 Added: (Pages.progress request ~logging ~viewer
377 Added: {
378 Added: View_model.Progress_page.exercises;
379 Added: selected_id = None;
380 Added: chart = None;
381 Added: insufficient = false;
382 Added: })
383 Added: | Some exercise ->
384 Added: Service.exercise_history t.service trainee.Trainee.id exercise
385 Added: >>= fun history ->
386 Added: let points =
387 Added: List.map
388 Added: (fun o ->
389 Added: let effort = Evidence.Observation.effort o in
390 Added: let at =
391 Added: Recovery.timestamp_to_unix_seconds
392 Added: (Evidence.Observation.performed_at o)
393 Added: in
394 Added: {
395 Added: View_model.Progress_page.date = date_of_unix at;
396 Added: at_unix = at;
397 Added: weight = Evidence.Stimulus.Effort.load effort;
398 Added: reps = Evidence.Stimulus.Effort.reps effort;
399 Added: })
400 Added: history
401 Added: in
402 Added: let chart, insufficient =
403 Added: if List.length points >= 2 then
404 Added: ( Some
405 Added: {
406 Added: View_model.Progress_page.exercise_name =
407 Added: Exercise.name exercise;
408 Added: points;
409 Added: },
410 Added: false )
411 Added: else (None, true)
412 Added: in
413 Added: html
414 Added: (Pages.progress request ~logging ~viewer
415 Added: {
416 Added: View_model.Progress_page.exercises;
417 Added: selected_id = Some (Exercise.id exercise :> string);
418 Added: chart;
419 Added: insufficient;
420 Added: })
421 Added:
334 422 let routes t =
335 423 [
424 Added: Dream_html.get Routes.progress (fun request ->
425 Added: authenticated t request (fun trainee -> progress t trainee request));
336 426 Dream_html.get Routes.logbook (fun request ->
337 427 authenticated t request (fun trainee -> index t trainee request));
338 428 Dream_html.post Routes.feedback (fun request ->
test/web/logbook.ml
index 8b17edab..37031c6a 100644..100644
@@ -25,8 +25,7 @@
25 25 "empty state when nothing is logged" true
26 26 (Option.is_some (index_from ~needle:"Nothing logged yet." page 0))
27 27 );
28 Removed: ( "a finished workout appears in the logbook table with a progress \
29 Removed: column",
28 Added: ( "a finished workout appears in the logbook table",
30 29 `Quick,
31 30 fun () ->
32 31 let c = client () in
@@ -40,14 +39,15 @@
40 39 "the logbook table is present" true
41 40 (Option.is_some
42 41 (index_from ~needle:"class=\"logbook-table\"" page 0));
43 Removed: List.iter
44 Removed: (fun heading ->
45 Removed: Alcotest.(check bool)
46 Removed: ("the " ^ heading ^ " column header")
47 Removed: true
48 Removed: (Option.is_some (index_from ~needle:heading page 0)))
49 Removed: [ "Duration"; "Progress" ] );
50 Removed: ( "an expanded row shows exact Heavy Duty progress evidence",
42 Added: (* Progress moved into the row menu, so there is no Progress column.
43 Added: Duration remains a column. *)
44 Added: Alcotest.(check bool)
45 Added: "the Duration column header" true
46 Added: (Option.is_some (index_from ~needle:"Duration" page 0));
47 Added: Alcotest.(check bool)
48 Added: "no Progress column header" false
49 Added: (Option.is_some (index_from ~needle:">Progress<" page 0)) );
50 Added: ( "the row menu shows exact Heavy Duty progress evidence",
51 51 `Quick,
52 52 fun () ->
53 53 let c, repo = client_with_repo () in
@@ -90,26 +90,22 @@
90 90 (Lwt_main.run
91 91 (Memory_repo.finish_workout repo trainee.Trainee.id
92 92 (saved ~started:172_800 ~load:12. ~reps:9)));
93 Removed: let compact = body (get c "/logbook") in
93 Added: (* Progress now lives in the row's three-dot menu, always in the
94 Added: markup — no expand round-trip. The later workout progressed, so
95 Added: its line carries the progress-verdict class and the exact delta. *)
96 Added: let page = body (get c "/logbook") in
94 97 List.iter
95 98 (fun needle ->
96 99 Alcotest.(check bool)
97 Removed: ("compact row shows " ^ needle)
100 Added: ("row menu shows " ^ needle)
98 101 true
99 Removed: (Option.is_some (index_from ~needle compact 0)))
100 Removed: [ "18 min"; "1/1 progressed"; "/logbook?expand=w2" ];
101 Removed: let expanded = body (get c "/logbook?expand=w2") in
102 Removed: List.iter
103 Removed: (fun needle ->
104 Removed: Alcotest.(check bool)
105 Removed: ("expanded row shows " ^ needle)
106 Removed: true
107 Removed: (Option.is_some (index_from ~needle expanded 0)))
102 Added: (Option.is_some (index_from ~needle page 0)))
108 103 [
109 Removed: "Progress since the prior workout";
104 Added: "18 min";
110 105 "Laterals";
111 106 "10 kg × 8 → 12 kg × 9";
112 107 "+2 kg · +1 rep";
108 Added: "verdict-progress";
113 109 ] );
114 110 ( "each saved workout row has an edit and confirmation-gated delete menu",
115 111 `Quick,
@@ -605,5 +601,130 @@
605 601 Alcotest.(check bool)
606 602 "the load gain since last performed is shown" true
607 603 (Option.is_some (index_from ~needle:"+10 kg" page 0)) );
604 Added: ( "the progress page charts an exercise with enough history",
605 Added: `Quick,
606 Added: fun () ->
607 Added: (* Two promoted imports of the same exercise give two points, enough
608 Added: to draw the dual-axis line chart. *)
609 Added: let c, repo = client_with_repo () in
610 Added: let _ = register c ~username:"lifter" ~password:"heavyduty1" in
611 Added: let trainee = the_trainee repo ~username:"lifter" in
612 Added: let header =
613 Added: "title,start_time,end_time,description,exercise_title,superset_id,exercise_notes,set_index,set_type,weight_kg,reps,distance_km,duration_seconds,rpe\r\n"
614 Added: in
615 Added: let source =
616 Added: header
617 Added: ^ "Legs,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Barbell \
618 Added: Squat,,,0,failure,100,8,,,\r\n"
619 Added: ^ "Legs,\"08 Jan 2024, 10:00\",\"08 Jan 2024, 11:00\",,Barbell \
620 Added: Squat,,,0,failure,110,8,,,\r\n"
621 Added: in
622 Added: let token = Option.get (csrf_token (body (get c "/profile"))) in
623 Added: let _ =
624 Added: post_multipart c "/profile/import"
625 Added: ~file:(Some ("csv", "workouts.csv", source))
626 Added: ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ]
627 Added: in
628 Added: let batch =
629 Added: List.hd
630 Added: (Lwt_main.run
631 Added: (Memory_repo.import_batches repo trainee.Trainee.id))
632 Added: in
633 Added: let bid =
634 Added: Workout_import.batch_id_to_string
635 Added: (Workout_import.batch_identity batch)
636 Added: in
637 Added: let review = "/profile/import/" ^ bid in
638 Added: List.iter
639 Added: (fun w ->
640 Added: let wid =
641 Added: Workout_import.workout_id_to_string
642 Added: (Workout_import.workout_identity w)
643 Added: in
644 Added: let t = Option.get (csrf_token (body (get c review))) in
645 Added: ignore
646 Added: (post c
647 Added: (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid
648 Added: wid)
649 Added: [
650 Added: ("dream.csrf", t);
651 Added: ("source_name", "Barbell Squat");
652 Added: ("exercise_id", "squats");
653 Added: ]))
654 Added: (Workout_import.batch_workouts batch);
655 Added: let t = Option.get (csrf_token (body (get c review))) in
656 Added: let _ =
657 Added: post c
658 Added: (Printf.sprintf "/profile/import/%s/promote" bid)
659 Added: [ ("dream.csrf", t) ]
660 Added: in
661 Added: (* The search submits the exercise name; the page returns its chart. *)
662 Added: let page = body (get c "/progress?exercise=Squats") in
663 Added: Alcotest.(check bool)
664 Added: "the SVG chart is rendered" true
665 Added: (Option.is_some (index_from ~needle:"progress-chart" page 0));
666 Added: Alcotest.(check bool)
667 Added: "the chart names the exercise" true
668 Added: (Option.is_some (index_from ~needle:"Squats" page 0)) );
669 Added: ( "the progress page reports too little history to chart",
670 Added: `Quick,
671 Added: fun () ->
672 Added: (* A single performance cannot draw a line. *)
673 Added: let c, repo = client_with_repo () in
674 Added: let _ = register c ~username:"lifter" ~password:"heavyduty1" in
675 Added: let trainee = the_trainee repo ~username:"lifter" in
676 Added: let header =
677 Added: "title,start_time,end_time,description,exercise_title,superset_id,exercise_notes,set_index,set_type,weight_kg,reps,distance_km,duration_seconds,rpe\r\n"
678 Added: in
679 Added: let source =
680 Added: header
681 Added: ^ "Legs,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Barbell \
682 Added: Squat,,,0,failure,100,8,,,\r\n"
683 Added: in
684 Added: let token = Option.get (csrf_token (body (get c "/profile"))) in
685 Added: let _ =
686 Added: post_multipart c "/profile/import"
687 Added: ~file:(Some ("csv", "workouts.csv", source))
688 Added: ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ]
689 Added: in
690 Added: let batch =
691 Added: List.hd
692 Added: (Lwt_main.run
693 Added: (Memory_repo.import_batches repo trainee.Trainee.id))
694 Added: in
695 Added: let bid =
696 Added: Workout_import.batch_id_to_string
697 Added: (Workout_import.batch_identity batch)
698 Added: in
699 Added: let wid =
700 Added: Workout_import.workout_id_to_string
701 Added: (Workout_import.workout_identity
702 Added: (List.hd (Workout_import.batch_workouts batch)))
703 Added: in
704 Added: let review = "/profile/import/" ^ bid in
705 Added: let t = Option.get (csrf_token (body (get c review))) in
706 Added: let _ =
707 Added: post c
708 Added: (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid)
709 Added: [
710 Added: ("dream.csrf", t);
711 Added: ("source_name", "Barbell Squat");
712 Added: ("exercise_id", "squats");
713 Added: ]
714 Added: in
715 Added: let t = Option.get (csrf_token (body (get c review))) in
716 Added: let _ =
717 Added: post c
718 Added: (Printf.sprintf "/profile/import/%s/workouts/%s/promote" bid wid)
719 Added: [ ("dream.csrf", t) ]
720 Added: in
721 Added: let page = body (get c "/progress?exercise=Squats") in
722 Added: Alcotest.(check bool)
723 Added: "no chart is drawn for one point" false
724 Added: (Option.is_some (index_from ~needle:"progress-chart" page 0));
725 Added: Alcotest.(check bool)
726 Added: "an insufficient-history message is shown" true
727 Added: (Option.is_some (index_from ~needle:"Not enough history" page 0))
728 Added: );
608 729 ] );
609 730 ]