1
1
open Lwt.Infix
2
2
module Repo = Hito_persistence.Sqlite_repo
3
+
Added:
module WI = Hito_app.Workout_import
3
4
4
5
module Service =
5
6
Hito_app.Service.Make
6
7
(Hito_persistence.Sqlite_repo)
7
8
(Hito_persistence.Bcrypt_hash)
8
9
9
-
Removed:
(* Seed two full rotations of the Ideal Routine as workout history, so the
10
-
Removed:
logbook and progress view have meaningful data from the start. Idempotent:
11
-
Removed:
skipped when the trainee already has history. Each rotation uses slightly
12
-
Removed:
higher loads so progress is visible. Workouts are spaced by HD1's recovery
13
-
Removed:
intervals: 48h between days, 72h after the final day. *)
14
-
Removed:
let workout_history repo trainee_id =
15
-
Removed:
Repo.history repo trainee_id >|= List.length >>= fun count ->
16
-
Removed:
if count > 0 then Lwt.return_unit
17
-
Removed:
else
18
-
Removed:
let routine = Routines.ideal in
19
-
Removed:
let workouts = Prescription.Routine.workouts routine in
20
-
Removed:
let clearance = Recovery.override Recovery.Ready in
21
-
Removed:
let get id =
22
-
Removed:
match Exercise.find_id id with Some e -> e | None -> failwith id
23
-
Removed:
in
24
-
Removed:
let effort ?(outcome = Evidence.Stimulus.Positive_failure) id load reps =
25
-
Removed:
Evidence.Stimulus.Effort.make ~exercise:(get id) ~load ~reps ~outcome
26
-
Removed:
in
27
-
Removed:
let single id load reps =
28
-
Removed:
Evidence.Stimulus.make (Evidence.Stimulus.Single (effort id load reps))
29
-
Removed:
in
30
-
Removed:
let pair iso_id iso_load iso_reps comp_id comp_load comp_reps =
31
-
Removed:
Evidence.Stimulus.make
32
-
Removed:
(Evidence.Stimulus.Pair
33
-
Removed:
{
34
-
Removed:
first = effort iso_id iso_load iso_reps;
35
-
Removed:
second = effort comp_id comp_load comp_reps;
36
-
Removed:
})
37
-
Removed:
in
38
-
Removed:
(* Two rotations of Day 1, 2, 3. The second rotation's loads are 5-10%
39
-
Removed:
higher so the progress judge counts exercises that beat their prior. *)
40
-
Removed:
let rotation_stimuli =
41
-
Removed:
[|
42
-
Removed:
(* Rotation 1 *)
43
-
Removed:
[
44
-
Removed:
(* Day 1 *)
45
-
Removed:
[
46
-
Removed:
pair "dumbbell-flyes" 18. 8 "incline-press" 55. 7;
47
-
Removed:
single "laterals" 10. 8;
48
-
Removed:
single "bent-over-laterals" 8. 9;
49
-
Removed:
pair "lying-french-press" 25. 8 "dips" 0. 7;
50
-
Removed:
];
51
-
Removed:
(* Day 2 *)
52
-
Removed:
[
53
-
Removed:
pair "pullovers" 20. 8 "close-grip-pulldowns" 50. 7;
54
-
Removed:
single "bent-over-rows" 50. 8;
55
-
Removed:
single "shrugs" 40. 10;
56
-
Removed:
single "hyperextensions" 0. 10;
57
-
Removed:
single "curls" 30. 8;
58
-
Removed:
];
59
-
Removed:
(* Day 3 *)
60
-
Removed:
[
61
-
Removed:
pair "leg-extensions" 40. 9 "leg-presses" 100. 8;
62
-
Removed:
single "leg-curls" 30. 9;
63
-
Removed:
single "calf-raises" 50. 12;
64
-
Removed:
single "sit-ups" 0. 15;
65
-
Removed:
];
66
-
Removed:
];
67
-
Removed:
(* Rotation 2: higher loads or reps so progress is visible *)
68
-
Removed:
[
69
-
Removed:
[
70
-
Removed:
pair "dumbbell-flyes" 20. 9 "incline-press" 60. 7;
71
-
Removed:
single "laterals" 12. 8;
72
-
Removed:
single "bent-over-laterals" 10. 9;
73
-
Removed:
pair "lying-french-press" 28. 8 "dips" 0. 8;
74
-
Removed:
];
75
-
Removed:
[
76
-
Removed:
pair "pullovers" 22. 8 "close-grip-pulldowns" 55. 7;
77
-
Removed:
single "bent-over-rows" 55. 8;
78
-
Removed:
single "shrugs" 45. 10;
79
-
Removed:
single "hyperextensions" 0. 12;
80
-
Removed:
single "curls" 32. 9;
81
-
Removed:
];
82
-
Removed:
[
83
-
Removed:
pair "leg-extensions" 45. 9 "leg-presses" 110. 8;
84
-
Removed:
single "leg-curls" 32. 10;
85
-
Removed:
single "calf-raises" 55. 12;
86
-
Removed:
single "sit-ups" 0. 18;
87
-
Removed:
];
88
-
Removed:
];
89
-
Removed:
|]
90
-
Removed:
in
91
-
Removed:
(* Space workouts: 48h between days, 72h after the last day of a rotation.
92
-
Removed:
Start the first workout 14 days ago so the seed feels recent. Precompute
93
-
Removed:
each workout's start time, then persist the sequence in Lwt — the seeder
94
-
Removed:
already runs under Lwt_main.run, so it must not nest another. *)
95
-
Removed:
let last_day = List.length workouts - 1 in
96
-
Removed:
let base = int_of_float (Unix.gettimeofday ()) - (14 * 86_400) in
97
-
Removed:
let planned, _ =
98
-
Removed:
Array.to_list rotation_stimuli
99
-
Removed:
|> List.concat_map (fun rotation ->
100
-
Removed:
List.mapi (fun day_idx stimuli -> (day_idx, stimuli)) rotation)
101
-
Removed:
|> List.fold_left
102
-
Removed:
(fun (acc, time) (day_idx, stimuli) ->
103
-
Removed:
let rest =
104
-
Removed:
if day_idx = last_day then
105
-
Removed:
Recovery.duration_to_seconds Prescription.Routine.cycle_rest
106
-
Removed:
else
107
-
Removed:
Recovery.duration_to_seconds
108
-
Removed:
Prescription.Routine.training_interval
109
-
Removed:
in
110
-
Removed:
(acc @ [ (day_idx, stimuli, time) ], time + 2400 + rest))
111
-
Removed:
([], base)
112
-
Removed:
in
113
-
Removed:
Lwt_list.iter_s
114
-
Removed:
(fun (day_idx, stimuli, time) ->
115
-
Removed:
let prescription = List.nth workouts day_idx in
116
-
Removed:
let started_at = Recovery.timestamp_of_unix_seconds time in
117
-
Removed:
let workout =
118
-
Removed:
Evidence.Workout.start prescription ~clearance ~started_at
119
-
Removed:
in
120
-
Removed:
let workout =
121
-
Removed:
List.fold_left Evidence.Workout.add_stimulus workout stimuli
122
-
Removed:
in
123
-
Removed:
let ended_at = Recovery.timestamp_of_unix_seconds (time + 2400) in
124
-
Removed:
let workout = Evidence.Workout.finish workout ~ended_at in
125
-
Removed:
Repo.finish_workout repo trainee_id workout >|= ignore)
126
-
Removed:
planned
10
+
Added:
(* The trainee's real Hevy export, imported as personal history through the
11
+
Added:
Workout_import use case rather than fabricated. The use case gates what the
12
+
Added:
core ever sees: only a set whose source exercise maps to the curated catalog
13
+
Added:
promotes to canonical evidence. Warmups, cardio, and bodyweight rows without
14
+
Added:
a load are dropped by the parser as warnings. A drop set is discarded the way
15
+
Added:
a warmup is — HD1 keeps only the drive to failure, never the strip rounds —
16
+
Added:
so it neither promotes nor blocks. An unmapped exercise blocks only itself.
17
+
Added:
All of that is doctrine, not loss: HD1 favours a curated catalog over
18
+
Added:
novelty, and a deviation is retained, never guessed at. *)
127
19
128
-
Removed:
let single_user repo ~password =
20
+
Added:
(* Hevy datetimes are local wall time. The export was recorded at UTC+2. *)
21
+
Added:
let utc_offset_seconds = 7200
22
+
Added:
23
+
Added:
(* Maps a Hevy export exercise name to a curated catalog id. Only names with a
24
+
Added:
defensible HD1 equivalent appear here. A name absent from this table stays
25
+
Added:
unmapped, so its workout blocks on [Unmapped_exercise] rather than promoting
26
+
Added:
a guess — "focus over novelty" (HD1, "The Routine"). Trap-bar and Romanian
27
+
Added:
deadlifts, the machine squat, machine chest and shoulder presses, and the
28
+
Added:
flat barbell bench have no Standard catalog entry, so they are left unmapped
29
+
Added:
on purpose. *)
30
+
Added:
let catalog_of_source name =
31
+
Added:
let id =
32
+
Added:
match name with
33
+
Added:
| "Deadlift (Barbell)" -> Some "deadlifts"
34
+
Added:
| "Squat (Barbell)" -> Some "squats"
35
+
Added:
| "Leg Press (Machine)" -> Some "leg-presses"
36
+
Added:
| "Leg Extension (Machine)" | "Single Leg Extensions" ->
37
+
Added:
Some "leg-extensions"
38
+
Added:
| "Lateral Raise (Dumbbell)" | "Lateral Raise (Machine)" -> Some "laterals"
39
+
Added:
| "Bicep Curl (Barbell)" -> Some "curls"
40
+
Added:
| "Preacher Curl (Barbell)" -> Some "preacher-curls"
41
+
Added:
| "Bent Over Row (Barbell)" -> Some "bent-over-rows"
42
+
Added:
| "Pullover (Dumbbell)" | "Pullover (Machine)" -> Some "pullovers"
43
+
Added:
(* A reverse (underhand) grip pulldown is HD1's close-grip, palms-up
44
+
Added:
pulldown. *)
45
+
Added:
| "Reverse Grip Lat Pulldown (Cable)" -> Some "close-grip-pulldowns"
46
+
Added:
| "Triceps Pushdown" -> Some "pressdowns"
47
+
Added:
| "Calf Extension (Machine)" | "Seated Calf Raise"
48
+
Added:
| "Standing Calf Raise (Machine)" ->
49
+
Added:
Some "calf-raises"
50
+
Added:
| _ -> None
51
+
Added:
in
52
+
Added:
Option.bind id Exercise.find_id
53
+
Added:
54
+
Added:
(* The acknowledgement retained for a workout whose normal sets promote. A
55
+
Added:
normal set is a working set the export did not mark as taken to failure, so
56
+
Added:
promoting it as failure-defined evidence is a deviation. It must be possible
57
+
Added:
but never silent (domain.md, "Encoding rules"). *)
58
+
Added:
let normal_ack = "Imported from Hevy: recorded as a working set to failure."
59
+
Added:
60
+
Added:
let read_file path =
61
+
Added:
let ic = open_in_bin path in
62
+
Added:
Fun.protect
63
+
Added:
~finally:(fun () -> close_in ic)
64
+
Added:
(fun () ->
65
+
Added:
let n = in_channel_length ic in
66
+
Added:
really_input_string ic n)
67
+
Added:
68
+
Added:
(* Distinct source exercise names in a workout, in first-appearance order. *)
69
+
Added:
let source_names workout =
70
+
Added:
List.fold_left
71
+
Added:
(fun acc (s : WI.source_set) ->
72
+
Added:
if List.mem s.WI.exercise_name acc then acc else s.WI.exercise_name :: acc)
73
+
Added:
[] (WI.workout_sets workout)
74
+
Added:
|> List.rev
75
+
Added:
76
+
Added:
let has_normal_set workout =
77
+
Added:
List.exists
78
+
Added:
(fun (s : WI.source_set) -> s.WI.set_type = WI.Normal)
79
+
Added:
(WI.workout_sets workout)
80
+
Added:
81
+
Added:
(* Import [csv_path] for [trainee_id] as one batch: store it, map every source
82
+
Added:
name that has a catalog equivalent, acknowledge the normal sets of any
83
+
Added:
workout that has one, then promote the whole batch. Promotion is partial —
84
+
Added:
a blocked workout is left in the batch for later review, never forced. *)
85
+
Added:
let import_csv repo trainee_id ~csv_path =
86
+
Added:
let source = read_file csv_path in
87
+
Added:
let fingerprint = Digest.to_hex (Digest.string source) in
88
+
Added:
let imported_at =
89
+
Added:
Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ()))
90
+
Added:
in
91
+
Added:
let batch_id = WI.batch_id ("hevy-" ^ String.sub fingerprint 0 12) in
92
+
Added:
match
93
+
Added:
Hito_import.Hevy_csv.parse ~batch_id ~fingerprint ~imported_at
94
+
Added:
~utc_offset_seconds source
95
+
Added:
with
96
+
Added:
| Error e ->
97
+
Added:
Format.eprintf "hito: cannot parse %s: %a@." csv_path
98
+
Added:
Hito_import.Hevy_csv.pp_error e;
99
+
Added:
exit 1
100
+
Added:
| Ok batch ->
101
+
Added:
let service = Service.make ~repo in
102
+
Added:
let unwrap step = function
103
+
Added:
| Ok v -> Lwt.return v
104
+
Added:
| Error _ ->
105
+
Added:
Format.eprintf "hito: import step %s failed@." step;
106
+
Added:
exit 1
107
+
Added:
in
108
+
Added:
Service.store_import_batch service trainee_id batch >>= unwrap "store"
109
+
Added:
>>= fun () ->
110
+
Added:
(* Map and confirm each workout, then promote the batch once. *)
111
+
Added:
Lwt_list.iter_s
112
+
Added:
(fun workout ->
113
+
Added:
let wid = WI.workout_identity workout in
114
+
Added:
Lwt_list.iter_s
115
+
Added:
(fun name ->
116
+
Added:
match catalog_of_source name with
117
+
Added:
| None -> Lwt.return_unit
118
+
Added:
| Some exercise ->
119
+
Added:
Service.map_import_exercise service trainee_id ~batch:batch_id
120
+
Added:
~workout:wid ~source_name:name exercise
121
+
Added:
>>= unwrap "map" >|= ignore)
122
+
Added:
(source_names workout)
123
+
Added:
>>= fun () ->
124
+
Added:
if has_normal_set workout then
125
+
Added:
Service.confirm_import_normal_sets service trainee_id
126
+
Added:
~batch:batch_id ~workout:wid ~acknowledgement:normal_ack
127
+
Added:
>>= unwrap "confirm" >|= ignore
128
+
Added:
else Lwt.return_unit)
129
+
Added:
(WI.batch_workouts batch)
130
+
Added:
>>= fun () ->
131
+
Added:
Service.promote_import_batch service trainee_id ~batch:batch_id
132
+
Added:
>>= unwrap "promote"
133
+
Added:
>|= fun result ->
134
+
Added:
Printf.eprintf
135
+
Added:
"hito: imported Hevy export: promoted %d, already promoted %d, blocked \
136
+
Added:
%d\n\
137
+
Added:
%!"
138
+
Added:
(List.length result.WI.promoted)
139
+
Added:
(List.length result.WI.already_promoted)
140
+
Added:
(List.length result.WI.blocked)
141
+
Added:
142
+
Added:
(* Idempotent: skip the import when the trainee already has promoted records. *)
143
+
Added:
let seed_history repo trainee_id ~csv_path =
144
+
Added:
Service.make ~repo |> fun service ->
145
+
Added:
Service.imported_records service trainee_id >>= fun records ->
146
+
Added:
if records <> [] then Lwt.return_unit
147
+
Added:
else import_csv repo trainee_id ~csv_path
148
+
Added:
149
+
Added:
let single_user repo ~password ~csv_path =
129
150
let service = Service.make ~repo in
130
151
Service.register service ~username:"blendux" ~password >>= function
131
152
| Ok trainee ->
132
153
Repo.set_active_routine repo trainee.Hito_app.Trainee.id
133
154
(Hito_app.Repository.routine_id "ideal")
134
-
Removed:
>>= fun () -> workout_history repo trainee.Hito_app.Trainee.id
155
+
Added:
>>= fun () -> seed_history repo trainee.Hito_app.Trainee.id ~csv_path
135
156
| Error `Username_taken -> (
136
157
match Hito_app.Trainee.username "blendux" with
137
158
| Error _ -> Lwt.return_unit