feat imported workouts on one line with a details menu

Show the source title verbatim -- do not append 'Imported' to it. An import's status now reads from its three-dot menu, not a tag. On mobile each workout holds a single line: monospaced date, duration, title, then the menu. For an imported workout the menu drawer lists each exercise, its effort, and the progress since that exercise was last performed. That comparison uses the merged native and imported history: add Progress.previous_observation (the baseline rule of workout_comparisons over a bare observation series) and Service.exercise_history to supply it. A first performance is marked as such; a later one shows the load or rep gain. Native rows keep their progress toggle on a second line -- progressive overload is the primary Heavy Duty signal and is not dropped.

Commit
b3410e1d34af8d29f0d8546e27937a174de9cb2a
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/app/service.ml
index ecad3383..274ee405 100644..100644
@@ -338,6 +338,15 @@
338 338 | assessment -> Ok assessment
339 339 | exception Progress.Invalid error -> Error error
340 340
341 Added: (* The merged native and imported observation series for one exercise,
342 Added: oldest first. The same merge the progress verdict is built on, exposed
343 Added: so a caller can compare a single observation against what came before it
344 Added: without reaching into either store. *)
345 Added: let exercise_history t trainee exercise =
346 Added: R.log t.repo trainee >>= fun log ->
347 Added: R.imported_records t.repo trainee >|= fun records ->
348 Added: merged_observations ~log ~records exercise
349 Added:
341 350 (* Diagnostics stay native-only. They name overtraining habits from a
342 351 workout's prescription, its volume, its clearance, and its extensions.
343 352 A promoted import carries none of that structure: it is a bare series of
@@ -516,6 +525,7 @@
516 525 let delete_record = Records.delete_record
517 526 let workout_log = Records.workout_log
518 527 let progress = Assessment.progress
528 Added: let exercise_history = Assessment.exercise_history
519 529 let diagnostics = Assessment.diagnostics
520 530 let record_feedback = Self_report.record
521 531 let feedback = Self_report.list
lib/app/service.mli
index 4107510c..db5b9191 100644..100644
@@ -192,6 +192,12 @@
192 192 Exercise.t ->
193 193 (Progress.assessment, Progress.error) result Lwt.t
194 194
195 Added: val exercise_history :
196 Added: t -> Trainee.id -> Exercise.t -> Evidence.Observation.t list Lwt.t
197 Added: (** The merged native and imported observations for one exercise, oldest
198 Added: first. The series {!progress} judges, exposed so a caller can compare a
199 Added: single observation against what preceded it. *)
200 Added:
195 201 val diagnostics : t -> Trainee.id -> Progress.diagnostic list Lwt.t
196 202 (** Habits the record shows that HD1 names as causes of overtraining. *)
197 203
lib/core/progress.ml
index a23f3e3d..0f6bc6a6 100644..100644
@@ -212,3 +212,27 @@
212 212 count and never re-derives the rule. An exercise with no earlier observation
213 213 does not count. *)
214 214 let workout_progress log workout = List.length (workout_changes log workout)
215 Added:
216 Added: (* The most recent observation of [target]'s exercise strictly before [target]
217 Added: was performed, taken from [observations] (any order). The same baseline rule
218 Added: as [previous_effort], but over a bare observation series rather than a log,
219 Added: so imported evidence — which is not a native workout — can still be compared
220 Added: against what came before it. *)
221 Added: let previous_observation observations (target : Evidence.Observation.t) =
222 Added: let exercise = Evidence.Observation.exercise target in
223 Added: let performed = Evidence.Observation.performed_at target in
224 Added: observations
225 Added: |> List.filter (fun (o : Evidence.Observation.t) ->
226 Added: Exercise.equal (Evidence.Observation.exercise o) exercise
227 Added: && Recovery.duration_to_seconds
228 Added: (Recovery.elapsed
229 Added: ~since:(Evidence.Observation.performed_at o)
230 Added: ~now:performed)
231 Added: > 0)
232 Added: |> List.sort (fun a b ->
233 Added: Int.compare
234 Added: (Recovery.timestamp_to_unix_seconds
235 Added: (Evidence.Observation.performed_at a))
236 Added: (Recovery.timestamp_to_unix_seconds
237 Added: (Evidence.Observation.performed_at b)))
238 Added: |> last
lib/core/progress.mli
index 8373650a..414789e9 100644..100644
@@ -87,3 +87,12 @@
87 87 An exercise with no earlier observation does not count, and the baseline is
88 88 the latest observation strictly before the workout started, so correcting a
89 89 saved workout never compares an exercise against itself. *)
90 Added:
91 Added: val previous_observation :
92 Added: Evidence.Observation.t list ->
93 Added: Evidence.Observation.t ->
94 Added: Evidence.Observation.t option
95 Added: (** The most recent observation of the target's exercise strictly before the
96 Added: target was performed, from the given series. The baseline rule of
97 Added: {!workout_comparisons}, over a bare observation list, so imported evidence
98 Added: can be compared against what came before it. *)
lib/presenter/pages_logbook.ml
index a9f045fa..409564c4 100644..100644
@@ -188,25 +188,63 @@
188 188 ];
189 189 ]
190 190
191 Removed: (* An imported entry has no prescription and no progress comparison. Its detail
192 Removed: lists the imported efforts verbatim. *)
193 Removed: let imported_detail_row (entry : View_model.Logbook_entry.t) =
194 Removed: tag "tr"
195 Removed: [ class_ "logbook-detail-row" ]
191 Added: (* The progress since an imported effort was last performed, as a compact
192 Added: delta. Empty string when there is no prior performance. *)
193 Added: let imported_delta (e : View_model.Imported_effort.t) =
194 Added: match (e.previous_load, e.previous_reps) with
195 Added: | Some pl, Some pr ->
196 Added: let load = e.load -. pl in
197 Added: let reps = e.reps - pr in
198 Added: let pieces =
199 Added: (if Float.equal load 0. then [] else [ Printf.sprintf "%+g kg" load ])
200 Added: @
201 Added: if reps = 0 then []
202 Added: else
203 Added: [
204 Added: Printf.sprintf "%s rep%s" (signed_number reps)
205 Added: (if abs reps = 1 then "" else "s");
206 Added: ]
207 Added: in
208 Added: if pieces = [] then "no change" else String.concat " · " pieces
209 Added: | _ -> "first performance"
210 Added:
211 Added: (* The three-dot menu for an imported workout. Its drawer lists each imported
212 Added: exercise with its effort and the progress since that exercise was last
213 Added: performed. This is where an import's detail lives -- there is no separate
214 Added: expandable detail row, and no tag on the title: the menu itself signals the
215 Added: workout was imported. *)
216 Added: let imported_menu (entry : View_model.Logbook_entry.t) =
217 Added: tag "details"
218 Added: [ class_ "logbook-row-menu" ]
196 219 [
197 Removed: tag "td"
198 Removed: [ Dream_html.string_attr "colspan" "5" ]
220 Added: tag "summary"
199 221 [
200 Removed: tag "section"
201 Removed: [ class_ "logbook-progress-detail" ]
202 Removed: [
203 Removed: tag "h2" [] [ txt "Imported efforts" ];
204 Removed: tag "ul"
205 Removed: [ class_ "logbook-change-list" ]
206 Removed: (List.map
207 Removed: (fun line -> tag "li" [] [ txt "%s" line ])
208 Removed: entry.View_model.Logbook_entry.imported_lines);
209 Removed: ];
222 Added: Dream_html.string_attr "aria-label" "Imported exercises for %s on %s"
223 Added: entry.workout_name entry.date;
224 Added: ]
225 Added: [ txt "⋮" ];
226 Added: tag "div"
227 Added: [
228 Added: class_ "logbook-row-menu-drawer logbook-import-drawer";
229 Added: Dream_html.string_attr "role" "menu";
230 Added: Dream_html.string_attr "aria-label" "Imported exercises";
231 Added: ]
232 Added: [
233 Added: tag "ul"
234 Added: [ class_ "logbook-change-list" ]
235 Added: (List.map
236 Added: (fun (e : View_model.Imported_effort.t) ->
237 Added: tag "li" []
238 Added: [
239 Added: tag "strong" [] [ txt "%s" e.exercise_name ];
240 Added: tag "span"
241 Added: [ class_ "change-efforts" ]
242 Added: [ txt "%g kg × %d" e.load e.reps ];
243 Added: tag "span"
244 Added: [ class_ "change-delta" ]
245 Added: [ txt "%s" (imported_delta e) ];
246 Added: ])
247 Added: entry.View_model.Logbook_entry.imported_efforts);
210 248 ];
211 249 ]
212 250
@@ -275,12 +313,7 @@
275 313 [ txt "%s %s" label (if expanded then "▴" else "▾") ]
276 314 in
277 315 let progress =
278 Removed: if entry.View_model.Logbook_entry.imported then
279 Removed: if entry.imported_lines = [] then
280 Removed: tag "span"
281 Removed: [ class_ "logbook-no-progress" ]
282 Removed: [ txt "Imported" ]
283 Removed: else expand_toggle ~label:"Imported efforts"
316 Added: if entry.View_model.Logbook_entry.imported then txt ""
284 317 else if entry.compared_exercises = 0 then
285 318 tag "span"
286 319 [ class_ "logbook-no-progress" ]
@@ -296,21 +329,15 @@
296 329 entry.progressed_exercises
297 330 entry.compared_exercises)
298 331 in
299 Removed: (* A read-only import carries an Imported tag and no action
300 Removed: menu. A native record keeps Edit and Delete. *)
332 Added: (* The title shows verbatim -- no tag. An import's imported
333 Added: status is signalled by its three-dot menu, which also
334 Added: carries its exercises and progress. A native record's menu
335 Added: carries Edit and Delete. *)
301 336 let name_cell =
302 Removed: if entry.imported then
303 Removed: tag "td" []
304 Removed: [
305 Removed: txt "%s" entry.workout_name;
306 Removed: tag "span"
307 Removed: [ class_ "logbook-source-tag" ]
308 Removed: [ txt " Imported" ];
309 Removed: ]
310 Removed: else tag "td" [] [ txt "%s" entry.workout_name ]
337 Added: tag "td" [] [ txt "%s" entry.workout_name ]
311 338 in
312 339 let actions =
313 Removed: if entry.imported then []
340 Added: if entry.imported then [ imported_menu entry ]
314 341 else
315 342 [
316 343 tag "details"
@@ -364,11 +391,11 @@
364 391 tag "td" [ class_ "logbook-actions" ] actions;
365 392 ]
366 393 in
367 Removed: let detail =
368 Removed: if entry.imported then imported_detail_row entry
369 Removed: else progress_detail_row entry
370 Removed: in
371 Removed: if expanded then [ row; detail ] else [ row ])
394 Added: (* Only a native record has an expandable progress detail
395 Added: row. An import's detail lives in its three-dot menu. *)
396 Added: if expanded && not entry.imported then
397 Added: [ row; progress_detail_row entry ]
398 Added: else [ row ])
372 399 records);
373 400 ]);
374 401 tag "section"
lib/presenter/view_model.ml
index 5234cee1..281042b7 100644..100644
@@ -130,13 +130,29 @@
130 130 deltas from these plain values, never from a core effort. *)
131 131 end
132 132
133 Added: module Imported_effort = struct
134 Added: type t = {
135 Added: exercise_name : string;
136 Added: load : float; (** The imported effort's load, in kg. *)
137 Added: reps : int;
138 Added: previous_load : float option;
139 Added: (** The same exercise's most recent prior performance, when one exists.
140 Added: [None] for a first performance. *)
141 Added: previous_reps : int option;
142 Added: }
143 Added: (** One imported exercise effort with the progress since it was last
144 Added: performed. The presenter derives the delta from the plain values; a first
145 Added: performance carries no previous. *)
146 Added: end
147 Added:
133 148 module Logbook_entry = struct
134 149 type t = {
135 150 id : string; (** Record id for links and URL-addressable expansion. *)
136 151 imported : bool;
137 Removed: (** True for a promoted third-party import. Shown read-only and tagged;
138 Removed: it feeds progress per exercise but is never a prescribed native
139 Removed: record. *)
152 Added: (** True for a promoted third-party import. Shown read-only; its title
153 Added: is the source title verbatim, and its imported status is signalled
154 Added: by the row's three-dot menu, not by any tag on the title. It feeds
155 Added: progress per exercise but is never a prescribed native record. *)
140 156 at_unix : int; (** Start time in Unix seconds, for interleaving by date. *)
141 157 workout_name : string;
142 158 date : string; (** Workout date, pre-formatted "YYYY-MM-DD" in UTC. *)
@@ -149,9 +165,9 @@
149 165 changes : Logbook_change.t list;
150 166 (** Exact positive overload changes, in workout performance order.
151 167 Native only; empty for an import. *)
152 Removed: imported_lines : string list;
153 Removed: (** One pre-phrased line per imported effort (exercise, load, reps).
154 Removed: Empty for a native entry. *)
168 Added: imported_efforts : Imported_effort.t list;
169 Added: (** One per imported effort, in source order, each with the progress
170 Added: since it was last performed. Empty for a native entry. *)
155 171 }
156 172 (** One logbook entry as the table shows it: a native prescribed workout or a
157 173 read-only imported workout, interleaved by date. Compact evidence for
lib/web/assets/hito.css
index c82e7957..0242d7e8 100644..100644
@@ -554,11 +554,12 @@
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 minmax(0, 1fr) auto;
557 Added: grid-template-columns: auto auto minmax(0, 1fr) auto;
558 558 grid-template-areas:
559 Removed: "date workout actions"
560 Removed: "duration progress actions";
559 Added: "date duration workout actions"
560 Added: "progress progress progress progress";
561 561 gap: 0.25rem 0.5rem;
562 Added: align-items: center;
562 563 border: 1px solid var(--rule-strong);
563 564 border-radius: var(--radius);
564 565 background: var(--paper-raised);
@@ -571,45 +572,45 @@
571 572 border: none;
572 573 text-align: left;
573 574 }
575 Added: /* One line per workout on mobile: monospaced date, duration, then the title,
576 Added: then the row menu. A native record's progress toggle drops to a second line;
577 Added: an import carries no progress cell here, so its row stays a single line and
578 Added: its detail lives in the three-dot menu. */
574 579 .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(1) {
575 580 grid-area: date;
576 Removed: align-self: baseline;
577 Removed: padding: 0.75rem 0 0 0.85rem;
581 Added: padding: 0.7rem 0 0.7rem 0.85rem;
578 582 font-family: var(--mono);
579 583 line-height: 1.25;
580 584 white-space: nowrap;
581 585 }
582 Removed: .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(1)::after {
583 Removed: margin-left: 0.5rem;
584 Removed: content: "—";
585 Removed: color: var(--muted-ink);
586 Removed: }
587 586 .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(2) {
588 587 grid-area: workout;
589 Removed: align-self: baseline;
590 588 min-width: 0;
591 Removed: padding: 0.75rem 0 0;
589 Added: padding: 0.7rem 0;
592 590 font-weight: 400;
593 591 line-height: 1.25;
592 Added: overflow: hidden;
593 Added: text-overflow: ellipsis;
594 Added: white-space: nowrap;
594 595 }
595 Removed: /* Duration sits on the second line beneath the date, beside the primary
596 Removed: progress signal, so the narrow ledger still reports how long the workout
597 Removed: took without crowding progressive overload. */
598 596 .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(3) {
599 597 grid-area: duration;
600 Removed: align-self: baseline;
601 Removed: padding: 0 0 0.75rem 0.85rem;
598 Added: padding: 0.7rem 0;
602 599 color: var(--muted-ink);
600 Added: white-space: nowrap;
603 601 }
604 602 .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(4) {
605 603 grid-area: progress;
606 Removed: padding: 0 0 0.75rem 0;
604 Added: padding: 0 0 0.75rem 0.85rem;
607 605 text-align: left;
608 606 font-family: var(--sans);
609 607 }
608 Added: .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(4):empty {
609 Added: display: none;
610 Added: }
610 611 .logbook-table tbody > tr:not(.logbook-detail-row) > td:nth-child(5) {
611 612 grid-area: actions;
612 Removed: padding: 0.35rem 0.35rem 0 0;
613 Added: padding: 0 0.35rem 0 0;
613 614 text-align: right;
614 615 }
615 616
@@ -693,6 +694,15 @@
693 694 .logbook-row-menu-drawer a.danger:hover {
694 695 color: var(--on-primary);
695 696 background: var(--oxblood);
697 Added: }
698 Added:
699 Added: /* The imported workout's menu drawer holds an exercise/progress list rather
700 Added: than action links, so it is wider and its items are read, not clicked. */
701 Added: .logbook-import-drawer {
702 Added: min-width: 15rem;
703 Added: max-width: min(22rem, 80vw);
704 Added: padding: 0.6rem 0.8rem;
705 Added: text-align: left;
696 706 }
697 707
698 708 .confirm-dialog button.danger {
lib/web/handlers_logbook.ml
index 826d7f9d..d39cf292 100644..100644
@@ -76,38 +76,83 @@
76 76 progressed_exercises = List.length changes;
77 77 compared_exercises = List.length comparisons;
78 78 changes;
79 Removed: imported_lines = [];
79 Added: imported_efforts = [];
80 80 }
81 81
82 82 (* A promoted import: canonical evidence, read-only. It carries no
83 83 prescription, so the native progress columns do not apply. Its detail lists
84 84 the imported efforts verbatim. It still feeds progress per exercise through
85 85 the service; the logbook only shows it. *)
86 Removed: let imported_line (o : Evidence.Observation.t) =
86 Added: let imported_effort_view ~history (o : Evidence.Observation.t) :
87 Added: View_model.Imported_effort.t =
87 88 let effort = Evidence.Observation.effort o in
88 Removed: Printf.sprintf "%s — %g kg × %d"
89 Removed: (Exercise.name (Evidence.Observation.exercise o))
90 Removed: (Evidence.Stimulus.Effort.load effort)
91 Removed: (Evidence.Stimulus.Effort.reps effort)
89 Added: let previous = Progress.previous_observation history o in
90 Added: {
91 Added: exercise_name = Exercise.name (Evidence.Observation.exercise o);
92 Added: load = Evidence.Stimulus.Effort.load effort;
93 Added: reps = Evidence.Stimulus.Effort.reps effort;
94 Added: previous_load =
95 Added: Option.map
96 Added: (fun p ->
97 Added: Evidence.Stimulus.Effort.load (Evidence.Observation.effort p))
98 Added: previous;
99 Added: previous_reps =
100 Added: Option.map
101 Added: (fun p ->
102 Added: Evidence.Stimulus.Effort.reps (Evidence.Observation.effort p))
103 Added: previous;
104 Added: }
92 105
93 Removed: let imported_entry_view (record : Repository.imported_record) :
94 Removed: View_model.Logbook_entry.t =
106 Added: (* Build one imported entry. Each imported effort carries the progress since
107 Added: that exercise was last performed, compared against the merged native and
108 Added: imported history the service supplies. History is fetched once per distinct
109 Added: exercise in the record, so a workout of repeated exercises stays cheap. *)
110 Added: let imported_entry_view t trainee (record : Repository.imported_record) :
111 Added: View_model.Logbook_entry.t Lwt.t =
95 112 let started =
96 113 Recovery.timestamp_to_unix_seconds record.Repository.started_at
97 114 in
98 115 let ended = Recovery.timestamp_to_unix_seconds record.Repository.ended_at in
99 Removed: {
100 Removed: id = Workout_import.workout_id_to_string record.Repository.workout_id;
101 Removed: imported = true;
102 Removed: at_unix = started;
103 Removed: workout_name = record.Repository.title;
104 Removed: date = date_of_unix started;
105 Removed: duration = Printf.sprintf "%d min" ((ended - started) / 60);
106 Removed: progressed_exercises = 0;
107 Removed: compared_exercises = 0;
108 Removed: changes = [];
109 Removed: imported_lines = List.map imported_line record.Repository.observations;
110 Removed: }
116 Added: let observations = record.Repository.observations in
117 Added: (* Distinct exercises in the record, each fetched once. *)
118 Added: let exercises =
119 Added: List.fold_left
120 Added: (fun acc o ->
121 Added: let e = Evidence.Observation.exercise o in
122 Added: if List.exists (Exercise.equal e) acc then acc else e :: acc)
123 Added: [] observations
124 Added: |> List.rev
125 Added: in
126 Added: Lwt_list.map_s
127 Added: (fun e ->
128 Added: Service.exercise_history t.service trainee e >|= fun h -> (e, h))
129 Added: exercises
130 Added: >|= fun histories ->
131 Added: let history_of e =
132 Added: match List.find_opt (fun (x, _) -> Exercise.equal x e) histories with
133 Added: | Some (_, h) -> h
134 Added: | None -> []
135 Added: in
136 Added: let efforts =
137 Added: List.map
138 Added: (fun o ->
139 Added: let history = history_of (Evidence.Observation.exercise o) in
140 Added: imported_effort_view ~history o)
141 Added: observations
142 Added: in
143 Added: ({
144 Added: id = Workout_import.workout_id_to_string record.Repository.workout_id;
145 Added: imported = true;
146 Added: at_unix = started;
147 Added: workout_name = record.Repository.title;
148 Added: date = date_of_unix started;
149 Added: duration = Printf.sprintf "%d min" ((ended - started) / 60);
150 Added: progressed_exercises = 0;
151 Added: compared_exercises = 0;
152 Added: changes = [];
153 Added: imported_efforts = efforts;
154 Added: }
155 Added: : View_model.Logbook_entry.t)
111 156
112 157 let feedback_view (report : Feedback.t) : View_model.Feedback.report =
113 158 {
@@ -127,11 +172,13 @@
127 172 Service.workout_log t.service trainee.Trainee.id >>= fun log ->
128 173 Service.imported_records t.service trainee.Trainee.id >>= fun imported ->
129 174 Service.feedback t.service trainee.Trainee.id >>= fun feedback ->
175 Added: Lwt_list.map_s (imported_entry_view t trainee.Trainee.id) imported
176 Added: >>= fun imported_entries ->
130 177 let feedback = List.map feedback_view feedback in
131 178 (* Native prescribed records and read-only imports, interleaved by start
132 179 date, most recent first. *)
133 180 let records =
134 Removed: List.map (entry_view log) records @ List.map imported_entry_view imported
181 Added: List.map (entry_view log) records @ imported_entries
135 182 |> List.stable_sort (fun a b ->
136 183 Int.compare b.View_model.Logbook_entry.at_unix
137 184 a.View_model.Logbook_entry.at_unix)
test/web/logbook.ml
index a294c917..e42a3d49 100644..100644
@@ -444,8 +444,7 @@
444 444 "only the valid factor survives" true
445 445 (Feedback.signals (List.hd reports)
446 446 = [ Feedback.Difficulty Feedback.Better_than_usual ]) );
447 Removed: ( "a promoted import appears in the logbook tagged Imported and \
448 Removed: read-only",
447 Added: ( "a promoted import shows verbatim, read-only, with a three-dot menu",
449 448 `Quick,
450 449 fun () ->
451 450 (* Upload one failure set, map its exercise, promote it, then read
@@ -508,16 +507,103 @@
508 507 Alcotest.(check bool)
509 508 "the imported workout title is shown" true
510 509 (Option.is_some (index_from ~needle:"Legs" page 0));
510 Added: (* The title shows verbatim: no "Imported" text is appended to it,
511 Added: and the old source tag is gone. *)
512 Added: Alcotest.(check bool)
513 Added: "no source tag on the title" false
514 Added: (Option.is_some (index_from ~needle:"logbook-source-tag" page 0));
515 Added: Alcotest.(check bool)
516 Added: "the title is not suffixed with Imported" false
517 Added: (Option.is_some (index_from ~needle:"Legs Imported" page 0));
511 518 (* The import spans 10:00-11:00, so its duration is relevant and
512 519 shown, like a native workout. *)
513 520 Alcotest.(check bool)
514 521 "the imported workout duration is shown" true
515 522 (Option.is_some (index_from ~needle:"60 min" page 0));
523 Added: (* An import's status and detail live in a three-dot menu, whose
524 Added: drawer lists the imported exercise, its effort, and the progress
525 Added: since it was last performed (here a first performance). *)
516 526 Alcotest.(check bool)
517 Removed: "it carries the Imported tag" true
518 Removed: (Option.is_some (index_from ~needle:"logbook-source-tag" page 0));
527 Added: "the import three-dot menu drawer is present" true
528 Added: (Option.is_some
529 Added: (index_from ~needle:"logbook-import-drawer" page 0));
519 530 Alcotest.(check bool)
531 Added: "the imported exercise is listed" true
532 Added: (Option.is_some (index_from ~needle:"Squats" page 0));
533 Added: Alcotest.(check bool)
534 Added: "a first performance is marked as such" true
535 Added: (Option.is_some (index_from ~needle:"first performance" page 0));
536 Added: Alcotest.(check bool)
520 537 "a read-only import offers no delete action" false
521 538 (Option.is_some (index_from ~needle:"?delete=" page 0)) );
539 Added: ( "an imported exercise shows progress since it was last performed",
540 Added: `Quick,
541 Added: fun () ->
542 Added: (* Two imports of the same exercise on different days. The later
543 Added: one's menu reports the load gain since the earlier performance,
544 Added: not "first performance". *)
545 Added: let c, repo = client_with_repo () in
546 Added: let _ = register c ~username:"lifter" ~password:"heavyduty1" in
547 Added: let trainee = the_trainee repo ~username:"lifter" in
548 Added: let header =
549 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"
550 Added: in
551 Added: let source =
552 Added: header
553 Added: ^ "Legs,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Barbell \
554 Added: Squat,,,0,failure,100,8,,,\r\n"
555 Added: ^ "Legs,\"08 Jan 2024, 10:00\",\"08 Jan 2024, 11:00\",,Barbell \
556 Added: Squat,,,0,failure,110,8,,,\r\n"
557 Added: in
558 Added: let token = Option.get (csrf_token (body (get c "/profile"))) in
559 Added: let _ =
560 Added: post_multipart c "/profile/import"
561 Added: ~file:(Some ("csv", "workouts.csv", source))
562 Added: ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ]
563 Added: in
564 Added: let batch =
565 Added: List.hd
566 Added: (Lwt_main.run
567 Added: (Memory_repo.import_batches repo trainee.Trainee.id))
568 Added: in
569 Added: let bid =
570 Added: Workout_import.batch_id_to_string
571 Added: (Workout_import.batch_identity batch)
572 Added: in
573 Added: let review = "/profile/import/" ^ bid in
574 Added: (* Map the exercise in both workouts, then promote the whole batch. *)
575 Added: List.iter
576 Added: (fun w ->
577 Added: let wid =
578 Added: Workout_import.workout_id_to_string
579 Added: (Workout_import.workout_identity w)
580 Added: in
581 Added: let t = Option.get (csrf_token (body (get c review))) in
582 Added: ignore
583 Added: (post c
584 Added: (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid
585 Added: wid)
586 Added: [
587 Added: ("dream.csrf", t);
588 Added: ("source_name", "Barbell Squat");
589 Added: ("exercise_id", "squats");
590 Added: ]))
591 Added: (Workout_import.batch_workouts batch);
592 Added: let t = Option.get (csrf_token (body (get c review))) in
593 Added: let _ =
594 Added: post c
595 Added: (Printf.sprintf "/profile/import/%s/promote" bid)
596 Added: [ ("dream.csrf", t) ]
597 Added: in
598 Added: Alcotest.(check int)
599 Added: "both imports promoted" 2
600 Added: (List.length
601 Added: (Lwt_main.run
602 Added: (Memory_repo.imported_records repo trainee.Trainee.id)));
603 Added: let page = body (get c "/logbook") in
604 Added: (* The later import gained 10 kg over the earlier one. *)
605 Added: Alcotest.(check bool)
606 Added: "the load gain since last performed is shown" true
607 Added: (Option.is_some (index_from ~needle:"+10 kg" page 0)) );
522 608 ] );
523 609 ]