open Lwt.Infix module Repo = Hito_persistence.Sqlite_repo module WI = Hito_app.Workout_import module Service = Hito_app.Service.Make (Hito_persistence.Sqlite_repo) (Hito_persistence.Bcrypt_hash) module Development_seed = Hito_app.Development_seed.Make (Hito_persistence.Sqlite_repo) (Hito_persistence.Bcrypt_hash) (* The trainee's real Hevy export, imported as personal history through the Workout_import use case rather than fabricated. The use case gates what the core ever sees: only a set whose source exercise maps to the curated catalog promotes to canonical evidence. The parser drops warmups, cardio, and bodyweight rows without a load as warnings. The parser discards a drop set the way it discards a warmup, because HD1 keeps only the drive to failure, never the strip rounds. A drop set therefore neither promotes nor blocks. An unmapped exercise blocks only itself. All of that is doctrine, not loss: HD1 favours a curated catalog over novelty, and the app retains a deviation rather than guessing at it. *) (* Hevy datetimes are local wall time. The export was recorded at UTC+2. *) let utc_offset_seconds = 7200 (* Maps a Hevy export exercise name to a curated catalog id. Only names with a defensible HD1 equivalent appear here. A name absent from this table stays unmapped, so its workout blocks on [Unmapped_exercise] rather than promoting a guess — "focus over novelty" (HD1, "The Routine"). Trap-bar and Romanian deadlifts, the machine squat, machine chest and shoulder presses, and the flat barbell bench have no Standard catalog entry, so the table leaves them unmapped on purpose. *) let catalog_of_source name = let id = match name with | "Deadlift (Barbell)" -> Some "deadlifts" | "Squat (Barbell)" -> Some "squats" | "Leg Press (Machine)" -> Some "leg-presses" | "Leg Extension (Machine)" | "Single Leg Extensions" -> Some "leg-extensions" | "Lateral Raise (Dumbbell)" | "Lateral Raise (Machine)" -> Some "laterals" | "Bicep Curl (Barbell)" -> Some "curls" | "Preacher Curl (Barbell)" -> Some "preacher-curls" | "Bent Over Row (Barbell)" -> Some "bent-over-rows" | "Pullover (Dumbbell)" | "Pullover (Machine)" -> Some "pullovers" (* A reverse (underhand) grip pulldown is HD1's close-grip, palms-up pulldown. *) | "Reverse Grip Lat Pulldown (Cable)" -> Some "close-grip-pulldowns" | "Triceps Pushdown" -> Some "pressdowns" | "Calf Extension (Machine)" | "Seated Calf Raise" | "Standing Calf Raise (Machine)" -> Some "calf-raises" | _ -> None in Option.bind id Exercise.find_id (* The acknowledgement retained for a workout whose normal sets promote. A normal set is a working set the export did not mark as taken to failure, so promoting it as failure-defined evidence is a deviation. It must be possible but never silent (domain.md, "Encoding rules"). *) let normal_ack = "Imported from Hevy: recorded as a working set to failure." let read_file path = let ic = open_in_bin path in Fun.protect ~finally:(fun () -> close_in ic) (fun () -> let n = in_channel_length ic in really_input_string ic n) (* Distinct source exercise names in a workout, in first-appearance order. *) let source_names workout = List.fold_left (fun acc (s : WI.source_set) -> if List.mem s.WI.exercise_name acc then acc else s.WI.exercise_name :: acc) [] (WI.workout_sets workout) |> List.rev let has_normal_set workout = List.exists (fun (s : WI.source_set) -> s.WI.set_type = WI.Normal) (WI.workout_sets workout) (* Import [csv_path] for [trainee_id] as one batch: store it, map every source name that has a catalog equivalent, acknowledge the normal sets of any workout that has one, then promote the whole batch. Promotion is partial. The batch keeps a blocked workout for later review and never forces it. *) let import_csv repo trainee_id ~csv_path = let source = read_file csv_path in let fingerprint = Digest.to_hex (Digest.string source) in let imported_at = Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ())) in let batch_id = WI.batch_id ("hevy-" ^ String.sub fingerprint 0 12) in match Hito_import.Hevy_csv.parse ~batch_id ~fingerprint ~imported_at ~utc_offset_seconds source with | Error e -> Format.eprintf "hito: cannot parse %s: %a@." csv_path Hito_import.Hevy_csv.pp_error e; exit 1 | Ok batch -> let service = Service.make ~repo in let unwrap step = function | Ok v -> Lwt.return v | Error _ -> Format.eprintf "hito: import step %s failed@." step; exit 1 in Service.store_import_batch service trainee_id batch >>= unwrap "store" >>= fun () -> (* Map and confirm each workout, then promote the batch once. *) Lwt_list.iter_s (fun workout -> let wid = WI.workout_identity workout in Lwt_list.iter_s (fun name -> match catalog_of_source name with | None -> Lwt.return_unit | Some exercise -> Service.map_import_exercise service trainee_id ~batch:batch_id ~workout:wid ~source_name:name exercise >>= unwrap "map" >|= ignore) (source_names workout) >>= fun () -> if has_normal_set workout then Service.confirm_import_normal_sets service trainee_id ~batch:batch_id ~workout:wid ~acknowledgement:normal_ack >>= unwrap "confirm" >|= ignore else Lwt.return_unit) (WI.batch_workouts batch) >>= fun () -> Service.promote_import_batch service trainee_id ~batch:batch_id >>= unwrap "promote" >|= fun result -> Printf.eprintf "hito: imported Hevy export: promoted %d, already promoted %d, blocked \ %d\n\ %!" (List.length result.WI.promoted) (List.length result.WI.already_promoted) (List.length result.WI.blocked) (* Idempotent. The function skips the import when the trainee already has promoted records. *) let seed_history repo trainee_id ~csv_path = Service.make ~repo |> fun service -> Service.imported_records service trainee_id >>= fun records -> if records <> [] then Lwt.return_unit else if not (Sys.file_exists csv_path) then ( (* The Hevy export is a local, uncommitted convenience. A deployment ships no CSV, so its absence is normal: seed the account and routine, and skip the import rather than crashing on a missing file. *) Printf.eprintf "hito: seed CSV %s not found; skipping import\n%!" csv_path; Lwt.return_unit) else import_csv repo trainee_id ~csv_path let single_user repo ~password ~csv_path = let service = Service.make ~repo in Service.register service ~username:"blendux" ~password >>= function | Ok trainee -> Repo.set_active_routine repo trainee.Hito_app.Trainee.id (Hito_app.Repository.routine_id "ideal") >>= fun () -> seed_history repo trainee.Hito_app.Trainee.id ~csv_path | Error Service.Username_taken -> ( match Hito_app.Trainee.username "blendux" with | Error _ -> Lwt.return_unit | Ok username -> ( Repo.find_trainee_by_username repo username >>= fun trainee_opt -> match trainee_opt with | None -> Lwt.return_unit | Some trainee -> seed_history repo trainee.Hito_app.Trainee.id ~csv_path)) | Error (Service.Username_invalid e) -> Format.eprintf "hito: cannot seed user: %a@." Hito_app.Trainee.pp_username_error e; exit 1 let development_user repo = let now = Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ())) in Development_seed.seed repo ~now