[OCaml] High Intensity Training Online
1
module Make (R : Repository.S) = struct
2
open Lwt.Infix
3
4
type t = { repo : R.t }
5
6
let make ~repo = { repo }
7
8
(* Error types are part of the flat public interface, so they live at the top
9
level of the functor. The implementation below is grouped into focused
10
use-case modules over the shared [t]; the public names are re-exported at
11
the end, so callers and {!Service.mli} see one flat service. *)
12
13
type register_error = [ `Username of Trainee.username_error | `Username_taken ]
14
15
type change_username_error =
16
[ `Username of Trainee.username_error | `Username_taken | `Unknown ]
17
18
type change_password_error = [ `Incorrect_password | `Unknown ]
19
type error = Unknown_routine | Not_recovered of Recovery.readiness
20
21
let pp_error ppf = function
22
| Unknown_routine -> Format.pp_print_string ppf "no such routine"
23
| Not_recovered readiness ->
24
Format.fprintf ppf "not recovered: %a" Recovery.pp_readiness readiness
25
26
type log_error = No_workout_in_progress | Rejected of Evidence.Workout.error
27
28
let pp_log_error ppf = function
29
| No_workout_in_progress ->
30
Format.pp_print_string ppf "no workout in progress"
31
| Rejected e -> Evidence.Workout.pp_error ppf e
32
33
type edit_error = Unknown_workout | Rejected_edit of Evidence.Workout.error
34
type app_feedback_error = [ `Empty_message | `Unknown_feedback ]
35
36
(* Shared helpers over a routine and a log, used by more than one use case. *)
37
38
let routine t id =
39
match R.find_routine t.repo id with
40
| Some r -> Ok r
41
| None -> Error Unknown_routine
42
43
(* Where the cycle stands. With nothing logged, start at the beginning. *)
44
let next_of routine log =
45
match Evidence.Log.last_prescription log with
46
| Some last -> Prescription.Routine.workout_after routine last
47
| None -> List.hd (Prescription.Routine.workouts routine)
48
49
(* How long HD1 asks you to rest depends on where in the cycle you are, so
50
the recommendation comes from the last workout performed. *)
51
let recommended routine log =
52
match Evidence.Log.last_prescription log with
53
| Some last -> Prescription.Routine.recovery_after routine last
54
| None -> Prescription.Routine.training_interval
55
56
(* --- accounts --- *)
57
module Accounts = struct
58
let register t ~username ~password =
59
let open Lwt_result.Syntax in
60
(* Validate the username, hash the password, then create the account.
61
Username validation short-circuits into [register_error]; the password
62
carries no policy, so hashing always succeeds. *)
63
let* username =
64
Lwt.return
65
(Result.map_error (fun e -> `Username e) (Trainee.username username))
66
in
67
let credential = Trainee.hash_password password in
68
Lwt_result.map_error
69
(fun `Username_taken -> `Username_taken)
70
(R.create_trainee t.repo ~username ~credential)
71
72
let authenticate t ~username ~password =
73
match Trainee.username username with
74
| Error _ -> Lwt.return None
75
| Ok username -> (
76
R.find_trainee_by_username t.repo username >|= function
77
| Some trainee
78
when Trainee.verify_password trainee.Trainee.credential password ->
79
Some trainee
80
| _ -> None)
81
82
let find_trainee t id = R.find_trainee t.repo id
83
84
(* Rename the account. Validate the new name, then let the repository check
85
it is free. The trainee's own current name is accepted as a no-op. *)
86
let change_username t id ~username =
87
let open Lwt_result.Syntax in
88
let* username =
89
Lwt.return
90
(Result.map_error (fun e -> `Username e) (Trainee.username username))
91
in
92
Lwt_result.map_error
93
(fun `Username_taken -> `Username_taken)
94
(R.update_username t.repo id username)
95
96
(* Change the password. The current password must verify first, so a
97
hijacked session cannot silently reset it. Passwords carry no policy, so
98
any new password is accepted once the current one checks out. *)
99
let change_password t id ~current ~next =
100
R.find_trainee t.repo id >>= function
101
| None -> Lwt.return (Error `Unknown)
102
| Some trainee ->
103
if not (Trainee.verify_password trainee.Trainee.credential current)
104
then Lwt.return (Error `Incorrect_password)
105
else
106
R.update_credential t.repo id (Trainee.hash_password next)
107
>|= fun updated -> Option.to_result ~none:`Unknown updated
108
end
109
110
(* --- routines and selection --- *)
111
module Routines = struct
112
let list_routines t = R.list_routines t.repo
113
114
let select_routine t trainee id =
115
match R.find_routine t.repo id with
116
| None -> Lwt.return (Error Unknown_routine)
117
| Some _ -> R.set_active_routine t.repo trainee id >|= fun () -> Ok ()
118
119
let active_routine t trainee =
120
R.active_routine t.repo trainee >|= function
121
| None -> None
122
| Some id ->
123
Option.map (fun routine -> (id, routine)) (R.find_routine t.repo id)
124
125
let next_workout t trainee ~routine:id =
126
let open Lwt_result.Syntax in
127
let* r = Lwt.return (routine t id) in
128
let+ log = Lwt_result.ok (R.log t.repo trainee) in
129
next_of r log
130
131
let readiness t trainee ~routine:id ~now =
132
let open Lwt_result.Syntax in
133
let* r = Lwt.return (routine t id) in
134
let+ log = Lwt_result.ok (R.log t.repo trainee) in
135
Evidence.Log.readiness log ~now ~recommended:(recommended r log)
136
end
137
138
(* --- the workout in progress --- *)
139
module Workouts = struct
140
let begin_workout t trainee ~routine:id ~now ?override () =
141
let open Lwt_result.Syntax in
142
let* r = Lwt.return (routine t id) in
143
let* log = Lwt_result.ok (R.log t.repo trainee) in
144
let readiness =
145
Evidence.Log.readiness log ~now ~recommended:(recommended r log)
146
in
147
let clearance =
148
match (Recovery.clear readiness, override) with
149
| Some c, _ -> Some c
150
| None, Some () -> Some (Recovery.override readiness)
151
| None, None -> None
152
in
153
match clearance with
154
| None -> Lwt.return (Error (Not_recovered readiness))
155
| Some clearance ->
156
let workout =
157
Evidence.Workout.start (next_of r log) ~clearance ~started_at:now
158
in
159
let+ () =
160
Lwt_result.ok (R.set_in_progress t.repo trainee (Some workout))
161
in
162
workout
163
164
let in_progress t trainee = R.in_progress t.repo trainee
165
166
let log t trainee stimulus =
167
R.in_progress t.repo trainee >>= function
168
| None -> Lwt.return (Error No_workout_in_progress)
169
| Some workout -> (
170
match Evidence.Workout.add_stimulus workout stimulus with
171
| updated ->
172
R.set_in_progress t.repo trainee (Some updated) >|= fun () ->
173
Ok updated
174
| exception Evidence.Workout.Invalid error ->
175
Lwt.return (Error (Rejected error)))
176
177
let replace_current t trainee ~slot stimulus =
178
R.in_progress t.repo trainee >>= function
179
| None -> Lwt.return (Error No_workout_in_progress)
180
| Some workout -> (
181
match Evidence.Workout.replace_stimulus workout ~slot stimulus with
182
| updated ->
183
R.set_in_progress t.repo trainee (Some updated) >|= fun () ->
184
Ok updated
185
| exception Evidence.Workout.Invalid error ->
186
Lwt.return (Error (Rejected error)))
187
188
let finish t trainee ~ended_at =
189
R.in_progress t.repo trainee >>= function
190
| None -> Lwt.return None
191
| Some workout ->
192
let finished = Evidence.Workout.finish workout ~ended_at in
193
R.finish_workout t.repo trainee finished >|= fun record -> Some record
194
195
(* Discard the workout in progress. This clears the slot only; it never
196
writes to history, so a cancelled workout leaves no record. The doctrine
197
keeps plan and record distinct — an abandoned session is not evidence. *)
198
let cancel t trainee =
199
R.in_progress t.repo trainee >>= function
200
| None -> Lwt.return false
201
| Some _ -> R.set_in_progress t.repo trainee None >|= fun () -> true
202
end
203
204
(* --- saved records --- *)
205
module Records = struct
206
let find_record t trainee id = R.find t.repo trainee id
207
208
(* Apply every saved-record edit through the same lookup, validation, and
209
replacement path. This keeps add and correction operations consistent. *)
210
let update_record t trainee id ~edit =
211
R.find t.repo trainee id >>= function
212
| None -> Lwt.return (Error Unknown_workout)
213
| Some record -> (
214
match edit record.Repository.workout with
215
| Error error -> Lwt.return (Error (Rejected_edit error))
216
| Ok workout ->
217
R.replace t.repo trainee { record with Repository.workout }
218
>|= fun replaced ->
219
if replaced then Ok { record with Repository.workout }
220
else Error Unknown_workout)
221
222
let add_to_record t trainee id stimulus =
223
update_record t trainee id ~edit:(fun workout ->
224
try Ok (Evidence.Workout.add_stimulus workout stimulus)
225
with Evidence.Workout.Invalid error -> Error error)
226
227
let replace_in_record t trainee id ~slot stimulus =
228
update_record t trainee id ~edit:(fun workout ->
229
try Ok (Evidence.Workout.replace_stimulus workout ~slot stimulus)
230
with Evidence.Workout.Invalid error -> Error error)
231
232
let history t trainee = R.history t.repo trainee
233
end
234
235
(* --- what the record means --- *)
236
module Assessment = struct
237
let progress t trainee exercise =
238
R.log t.repo trainee >|= fun log ->
239
match Progression.assess (Evidence.Log.observations log exercise) with
240
| assessment -> Ok assessment
241
| exception Progression.Invalid error -> Error error
242
243
let diagnostics t trainee =
244
R.log t.repo trainee >|= fun log -> Progression.diagnose log
245
end
246
247
(* --- subjective feedback --- *)
248
module Feedback = struct
249
let record t trainee ~reported_at signals =
250
match Evidence.Feedback.make ~reported_at signals with
251
| report -> R.save_feedback t.repo trainee report >|= fun () -> Ok report
252
| exception Evidence.Feedback.Invalid error -> Lwt.return (Error error)
253
254
let list t trainee = R.feedback t.repo trainee
255
end
256
257
(* --- application feedback --- *)
258
module App_feedback = struct
259
let record t trainee ~submitted_at ~message =
260
if String.trim message = "" then Lwt.return (Error `Empty_message)
261
else
262
R.save_app_feedback t.repo trainee ~submitted_at ~message
263
>|= fun report -> Ok report
264
265
let list t trainee = R.app_feedback t.repo ~viewer:trainee
266
let upvote t trainee id = R.upvote_app_feedback t.repo ~voter:trainee id
267
268
let edit t trainee id ~message =
269
if String.trim message = "" then Lwt.return (Error `Empty_message)
270
else
271
R.update_app_feedback t.repo ~author:trainee id ~message >|= function
272
| true -> Ok ()
273
| false -> Error `Unknown_feedback
274
275
let remove t trainee id = R.delete_app_feedback t.repo ~author:trainee id
276
end
277
278
(* Re-export the use cases as one flat service, matching Service.mli. *)
279
280
let register = Accounts.register
281
let authenticate = Accounts.authenticate
282
let find_trainee = Accounts.find_trainee
283
let change_username = Accounts.change_username
284
let change_password = Accounts.change_password
285
let list_routines = Routines.list_routines
286
let select_routine = Routines.select_routine
287
let active_routine = Routines.active_routine
288
let next_workout = Routines.next_workout
289
let readiness = Routines.readiness
290
let begin_workout = Workouts.begin_workout
291
let in_progress = Workouts.in_progress
292
let log = Workouts.log
293
let replace_current = Workouts.replace_current
294
let finish = Workouts.finish
295
let cancel = Workouts.cancel
296
let find_record = Records.find_record
297
let add_to_record = Records.add_to_record
298
let replace_in_record = Records.replace_in_record
299
let history = Records.history
300
let progress = Assessment.progress
301
let diagnostics = Assessment.diagnostics
302
let record_feedback = Feedback.record
303
let feedback = Feedback.list
304
let record_app_feedback = App_feedback.record
305
let app_feedback = App_feedback.list
306
let upvote_app_feedback = App_feedback.upvote
307
let edit_app_feedback = App_feedback.edit
308
let remove_app_feedback = App_feedback.remove
309
end
310