[OCaml] High Intensity Training Online
1
open Lwt.Infix
2
module Repo = Hito_persistence.Sqlite_repo
3
module WI = Hito_app.Workout_import
4
5
module Service =
6
Hito_app.Service.Make
7
(Hito_persistence.Sqlite_repo)
8
(Hito_persistence.Bcrypt_hash)
9
10
module Development_seed =
11
Hito_app.Development_seed.Make
12
(Hito_persistence.Sqlite_repo)
13
(Hito_persistence.Bcrypt_hash)
14
15
(* The trainee's real Hevy export, imported as personal history through the
16
Workout_import use case rather than fabricated. The use case gates what the
17
core ever sees: only a set whose source exercise maps to the curated catalog
18
promotes to canonical evidence. The parser drops warmups, cardio, and
19
bodyweight rows without a load as warnings. The parser discards a drop set
20
the way it discards a warmup, because HD1 keeps only the drive to failure,
21
never the strip rounds. A drop set therefore neither promotes nor blocks. An
22
unmapped exercise blocks only itself. All of that is doctrine, not loss: HD1
23
favours a curated catalog over novelty, and the app retains a deviation
24
rather than guessing at it. *)
25
26
(* Hevy datetimes are local wall time. The export was recorded at UTC+2. *)
27
let utc_offset_seconds = 7200
28
29
(* Maps a Hevy export exercise name to a curated catalog id. Only names with a
30
defensible HD1 equivalent appear here. A name absent from this table stays
31
unmapped, so its workout blocks on [Unmapped_exercise] rather than promoting
32
a guess — "focus over novelty" (HD1, "The Routine"). Trap-bar and Romanian
33
deadlifts, the machine squat, machine chest and shoulder presses, and the
34
flat barbell bench have no Standard catalog entry, so the table leaves them
35
unmapped on purpose. *)
36
let catalog_of_source name =
37
let id =
38
match name with
39
| "Deadlift (Barbell)" -> Some "deadlifts"
40
| "Squat (Barbell)" -> Some "squats"
41
| "Leg Press (Machine)" -> Some "leg-presses"
42
| "Leg Extension (Machine)" | "Single Leg Extensions" ->
43
Some "leg-extensions"
44
| "Lateral Raise (Dumbbell)" | "Lateral Raise (Machine)" -> Some "laterals"
45
| "Bicep Curl (Barbell)" -> Some "curls"
46
| "Preacher Curl (Barbell)" -> Some "preacher-curls"
47
| "Bent Over Row (Barbell)" -> Some "bent-over-rows"
48
| "Pullover (Dumbbell)" | "Pullover (Machine)" -> Some "pullovers"
49
(* A reverse (underhand) grip pulldown is HD1's close-grip, palms-up
50
pulldown. *)
51
| "Reverse Grip Lat Pulldown (Cable)" -> Some "close-grip-pulldowns"
52
| "Triceps Pushdown" -> Some "pressdowns"
53
| "Calf Extension (Machine)" | "Seated Calf Raise"
54
| "Standing Calf Raise (Machine)" ->
55
Some "calf-raises"
56
| _ -> None
57
in
58
Option.bind id Exercise.find_id
59
60
(* The acknowledgement retained for a workout whose normal sets promote. A
61
normal set is a working set the export did not mark as taken to failure, so
62
promoting it as failure-defined evidence is a deviation. It must be possible
63
but never silent (domain.md, "Encoding rules"). *)
64
let normal_ack = "Imported from Hevy: recorded as a working set to failure."
65
66
let read_file path =
67
let ic = open_in_bin path in
68
Fun.protect
69
~finally:(fun () -> close_in ic)
70
(fun () ->
71
let n = in_channel_length ic in
72
really_input_string ic n)
73
74
(* Distinct source exercise names in a workout, in first-appearance order. *)
75
let source_names workout =
76
List.fold_left
77
(fun acc (s : WI.source_set) ->
78
if List.mem s.WI.exercise_name acc then acc else s.WI.exercise_name :: acc)
79
[] (WI.workout_sets workout)
80
|> List.rev
81
82
let has_normal_set workout =
83
List.exists
84
(fun (s : WI.source_set) -> s.WI.set_type = WI.Normal)
85
(WI.workout_sets workout)
86
87
(* Import [csv_path] for [trainee_id] as one batch: store it, map every source
88
name that has a catalog equivalent, acknowledge the normal sets of any
89
workout that has one, then promote the whole batch. Promotion is partial. The
90
batch keeps a blocked workout for later review and never forces it. *)
91
let import_csv repo trainee_id ~csv_path =
92
let source = read_file csv_path in
93
let fingerprint = Digest.to_hex (Digest.string source) in
94
let imported_at =
95
Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ()))
96
in
97
let batch_id = WI.batch_id ("hevy-" ^ String.sub fingerprint 0 12) in
98
match
99
Hito_import.Hevy_csv.parse ~batch_id ~fingerprint ~imported_at
100
~utc_offset_seconds source
101
with
102
| Error e ->
103
Format.eprintf "hito: cannot parse %s: %a@." csv_path
104
Hito_import.Hevy_csv.pp_error e;
105
exit 1
106
| Ok batch ->
107
let service = Service.make ~repo in
108
let unwrap step = function
109
| Ok v -> Lwt.return v
110
| Error _ ->
111
Format.eprintf "hito: import step %s failed@." step;
112
exit 1
113
in
114
Service.store_import_batch service trainee_id batch >>= unwrap "store"
115
>>= fun () ->
116
(* Map and confirm each workout, then promote the batch once. *)
117
Lwt_list.iter_s
118
(fun workout ->
119
let wid = WI.workout_identity workout in
120
Lwt_list.iter_s
121
(fun name ->
122
match catalog_of_source name with
123
| None -> Lwt.return_unit
124
| Some exercise ->
125
Service.map_import_exercise service trainee_id ~batch:batch_id
126
~workout:wid ~source_name:name exercise
127
>>= unwrap "map" >|= ignore)
128
(source_names workout)
129
>>= fun () ->
130
if has_normal_set workout then
131
Service.confirm_import_normal_sets service trainee_id
132
~batch:batch_id ~workout:wid ~acknowledgement:normal_ack
133
>>= unwrap "confirm" >|= ignore
134
else Lwt.return_unit)
135
(WI.batch_workouts batch)
136
>>= fun () ->
137
Service.promote_import_batch service trainee_id ~batch:batch_id
138
>>= unwrap "promote"
139
>|= fun result ->
140
Printf.eprintf
141
"hito: imported Hevy export: promoted %d, already promoted %d, blocked \
142
%d\n\
143
%!"
144
(List.length result.WI.promoted)
145
(List.length result.WI.already_promoted)
146
(List.length result.WI.blocked)
147
148
(* Idempotent. The function skips the import when the trainee already has
149
promoted records. *)
150
let seed_history repo trainee_id ~csv_path =
151
Service.make ~repo |> fun service ->
152
Service.imported_records service trainee_id >>= fun records ->
153
if records <> [] then Lwt.return_unit
154
else if not (Sys.file_exists csv_path) then (
155
(* The Hevy export is a local, uncommitted convenience. A deployment ships
156
no CSV, so its absence is normal: seed the account and routine, and skip
157
the import rather than crashing on a missing file. *)
158
Printf.eprintf "hito: seed CSV %s not found; skipping import\n%!" csv_path;
159
Lwt.return_unit)
160
else import_csv repo trainee_id ~csv_path
161
162
let single_user repo ~password ~csv_path =
163
let service = Service.make ~repo in
164
Service.register service ~username:"blendux" ~password >>= function
165
| Ok trainee ->
166
Repo.set_active_routine repo trainee.Hito_app.Trainee.id
167
(Hito_app.Repository.routine_id "ideal")
168
>>= fun () -> seed_history repo trainee.Hito_app.Trainee.id ~csv_path
169
| Error Service.Username_taken -> (
170
match Hito_app.Trainee.username "blendux" with
171
| Error _ -> Lwt.return_unit
172
| Ok username -> (
173
Repo.find_trainee_by_username repo username >>= fun trainee_opt ->
174
match trainee_opt with
175
| None -> Lwt.return_unit
176
| Some trainee ->
177
seed_history repo trainee.Hito_app.Trainee.id ~csv_path))
178
| Error (Service.Username_invalid e) ->
179
Format.eprintf "hito: cannot seed user: %a@."
180
Hito_app.Trainee.pp_username_error e;
181
exit 1
182
183
let development_user repo =
184
let now =
185
Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ()))
186
in
187
Development_seed.seed repo ~now
188