Skip to main content

max / ripgrow

12.2 KB · 354 lines History Blame Raw
1 //! Per-kind effort layer.
2 //!
3 //! Phase 4 splits the single `sets` table into one table per effort kind
4 //! (`reps_sets`, `timed_sets`, `distance_sets`). Each kind gets its own
5 //! typed row shape, its own SQL, and its own protocol logic; the shared
6 //! state machine reasons purely in terms of `SessionOutcome` and doesn't
7 //! care what the numbers represent.
8 //!
9 //! The [`Kind`] trait is the contract every kind implements. It carries
10 //! associated types (`Payload`, `Set`, `Prescription`) plus a fixed set
11 //! of DB, evaluation, and prescription methods, so adding a new kind is
12 //! a compile-time-verified list of "things to implement."
13 //!
14 //! Callsites that need to work across kinds dispatch on
15 //! [`EffortKind`] and pack the results into [`AnyPayload`] /
16 //! [`AnySet`] / [`AnyPrescription`] enums. Inside a kind's module
17 //! everything is strongly typed.
18
19 use chrono::NaiveDate;
20
21 use crate::db::Db;
22 use crate::error::Error;
23 use crate::progression::SessionOutcome;
24 use crate::templates::Exercise;
25 use crate::values::Rpe;
26
27 pub mod distance;
28 pub mod reps;
29 pub mod timed;
30
31 /// Discriminant carried on every exercise. Stored as text
32 /// (`'reps'` / `'timed'` / `'distance'`) in `exercises.effort_kind`,
33 /// which decides which per-kind sets table holds that exercise's history.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35 pub enum EffortKind {
36 Reps,
37 Timed,
38 Distance,
39 }
40
41 impl EffortKind {
42 pub fn as_str(&self) -> &'static str {
43 match self {
44 EffortKind::Reps => "reps",
45 EffortKind::Timed => "timed",
46 EffortKind::Distance => "distance",
47 }
48 }
49
50 pub fn parse(s: &str) -> Result<Self, Error> {
51 match s {
52 "reps" => Ok(EffortKind::Reps),
53 "timed" => Ok(EffortKind::Timed),
54 "distance" => Ok(EffortKind::Distance),
55 other => Err(Error::InvalidEffortKind(other.to_string())),
56 }
57 }
58 }
59
60 /// A completed session's result from the state machine's point of view.
61 /// Renamed here for clarity — the shared state machine takes this from
62 /// every kind's `evaluate` method.
63 pub type Outcome = SessionOutcome;
64
65 /// Generic version of the reps-only `PrescriptionResult`. Every kind
66 /// returns its own `P`.
67 #[derive(Debug, Clone, PartialEq)]
68 pub enum PrescriptionResult<P> {
69 /// No sessions logged for this exercise yet under this kind's
70 /// working history. UI prompts for a first-log seed.
71 NoHistory,
72 Prescribed(P),
73 }
74
75 /// Contract every effort kind implements. The associated types let each
76 /// kind carry its own payload / stored-set / prescription shape without
77 /// leaking into shared code. Callers that stay inside one kind speak
78 /// the associated types directly; callers that mix kinds use the
79 /// [`Any*`](AnyPayload) enums at the boundary.
80 pub trait Kind: 'static {
81 /// Kind-specific input for inserting a set. Load + reps for reps;
82 /// duration for timed; distance + duration for distance. RPE and the
83 /// diagnostic flag live outside `Payload` because they're universal.
84 type Payload: Clone + PartialEq + Send + Sync;
85 /// Fully-decoded row from this kind's sets table. Includes the
86 /// payload plus shared metadata (id, session_date, exercise_id,
87 /// set_index, is_diagnostic, rpe).
88 type Set: Clone + PartialEq + Send + Sync;
89 /// The prescription this kind produces from history.
90 type Prescription: Clone + PartialEq + Send + Sync;
91
92 /// Discriminant this kind maps to. Must match the `effort_kind`
93 /// column of every exercise that routes through this impl.
94 const DISCRIMINANT: EffortKind;
95 /// Name of the SQL table backing this kind (`reps_sets`, etc.).
96 const TABLE: &'static str;
97
98 // ---- persistence ---------------------------------------------------
99
100 /// Append a set. The `is_diagnostic` flag decides which downstream
101 /// queries the row participates in; see the per-kind list functions
102 /// for the filtering rules.
103 fn append(
104 db: &Db,
105 exercise_id: i64,
106 date: NaiveDate,
107 payload: Self::Payload,
108 rpe: Rpe,
109 is_diagnostic: bool,
110 ) -> Result<i64, Error>;
111
112 /// Every set for `exercise_id` on `date`, in set-index order.
113 /// Includes diagnostic sets (the log screen shows every row that
114 /// was actually recorded).
115 fn list_sets_for_session(
116 db: &Db,
117 exercise_id: i64,
118 date: NaiveDate,
119 ) -> Result<Vec<Self::Set>, Error>;
120
121 /// Every non-diagnostic session for `exercise_id`, oldest first,
122 /// grouped by date. Diagnostic sets are filtered so the progression
123 /// state machine only walks real working sessions.
124 fn list_sessions_for_exercise(
125 db: &Db,
126 exercise_id: i64,
127 ) -> Result<Vec<Vec<Self::Set>>, Error>;
128
129 /// Delete the most recent diagnostic set for `exercise_id` on
130 /// `date`. No-op if there isn't one.
131 fn delete_last_diagnostic_on_date(
132 db: &Db,
133 exercise_id: i64,
134 date: NaiveDate,
135 ) -> Result<(), Error>;
136
137 // ---- state machine / prescription ---------------------------------
138
139 /// Reduce a single session's sets to a [`SessionOutcome`] the shared
140 /// state machine can process. Each kind decides what "target hit"
141 /// means for its payload shape.
142 fn evaluate(sets: &[Self::Set]) -> Outcome;
143
144 /// Compute the next prescription from full history + exercise-level
145 /// config (increment, etc.). Handles the diagnostic-seed fallback
146 /// for kinds that support it.
147 fn compute_prescription(
148 db: &Db,
149 exercise: &Exercise,
150 ) -> Result<PrescriptionResult<Self::Prescription>, Error>;
151 }
152
153 // ---- runtime-typed wrappers ------------------------------------------------
154
155 /// Untyped payload for insertion at a dispatch boundary. Callers that
156 /// have parsed a payload from user input use this to hand off to the
157 /// per-kind append via [`append_any`].
158 #[derive(Debug, Clone, PartialEq)]
159 pub enum AnyPayload {
160 Reps(<reps::RepsKind as Kind>::Payload),
161 Timed(<timed::TimedKind as Kind>::Payload),
162 Distance(<distance::DistanceKind as Kind>::Payload),
163 }
164
165 impl AnyPayload {
166 pub fn kind(&self) -> EffortKind {
167 match self {
168 AnyPayload::Reps(_) => EffortKind::Reps,
169 AnyPayload::Timed(_) => EffortKind::Timed,
170 AnyPayload::Distance(_) => EffortKind::Distance,
171 }
172 }
173 }
174
175 /// Untyped stored-set for reads that cross kinds (rare — most reads
176 /// stay inside one kind). Useful for the log screen when we render a
177 /// mixed-kind history view.
178 #[derive(Debug, Clone, PartialEq)]
179 pub enum AnySet {
180 Reps(<reps::RepsKind as Kind>::Set),
181 Timed(<timed::TimedKind as Kind>::Set),
182 Distance(<distance::DistanceKind as Kind>::Set),
183 }
184
185 impl AnySet {
186 pub fn kind(&self) -> EffortKind {
187 match self {
188 AnySet::Reps(_) => EffortKind::Reps,
189 AnySet::Timed(_) => EffortKind::Timed,
190 AnySet::Distance(_) => EffortKind::Distance,
191 }
192 }
193 }
194
195 /// Untyped prescription. Every callsite that displays a "next session"
196 /// number ends up unpacking this to route through kind-specific
197 /// rendering (`format_prescription`).
198 #[derive(Debug, Clone, PartialEq)]
199 pub enum AnyPrescription {
200 Reps(<reps::RepsKind as Kind>::Prescription),
201 Timed(<timed::TimedKind as Kind>::Prescription),
202 Distance(<distance::DistanceKind as Kind>::Prescription),
203 }
204
205 impl AnyPrescription {
206 pub fn kind(&self) -> EffortKind {
207 match self {
208 AnyPrescription::Reps(_) => EffortKind::Reps,
209 AnyPrescription::Timed(_) => EffortKind::Timed,
210 AnyPrescription::Distance(_) => EffortKind::Distance,
211 }
212 }
213 }
214
215 /// Dispatch: hand a parsed [`AnyPayload`] to the right kind's `append`.
216 pub fn append_any(
217 db: &Db,
218 exercise: &Exercise,
219 date: NaiveDate,
220 payload: AnyPayload,
221 rpe: Rpe,
222 is_diagnostic: bool,
223 ) -> Result<i64, Error> {
224 if payload.kind() != exercise.effort_kind {
225 return Err(Error::EffortKindMismatch {
226 exercise: exercise.effort_kind.as_str(),
227 payload: payload.kind().as_str(),
228 });
229 }
230 match payload {
231 AnyPayload::Reps(p) => {
232 reps::RepsKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
233 }
234 AnyPayload::Timed(p) => {
235 timed::TimedKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
236 }
237 AnyPayload::Distance(p) => {
238 distance::DistanceKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
239 }
240 }
241 }
242
243 /// Dispatch: compute a prescription for the exercise regardless of kind.
244 /// Distance kinds always return `NoHistory` today (see
245 /// [`distance`](self::distance) module docs).
246 pub fn compute_prescription_any(
247 db: &Db,
248 exercise: &Exercise,
249 ) -> Result<PrescriptionResult<AnyPrescription>, Error> {
250 match exercise.effort_kind {
251 EffortKind::Reps => match reps::RepsKind::compute_prescription(db, exercise)? {
252 PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory),
253 PrescriptionResult::Prescribed(p) => {
254 Ok(PrescriptionResult::Prescribed(AnyPrescription::Reps(p)))
255 }
256 },
257 EffortKind::Timed => match timed::TimedKind::compute_prescription(db, exercise)? {
258 PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory),
259 PrescriptionResult::Prescribed(p) => {
260 Ok(PrescriptionResult::Prescribed(AnyPrescription::Timed(p)))
261 }
262 },
263 EffortKind::Distance => match distance::DistanceKind::compute_prescription(db, exercise)?
264 {
265 PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory),
266 PrescriptionResult::Prescribed(p) => Ok(PrescriptionResult::Prescribed(
267 AnyPrescription::Distance(p),
268 )),
269 },
270 }
271 }
272
273 #[cfg(test)]
274 mod tests {
275 use super::*;
276 use crate::values::{Load, LoadUnit, Reps};
277 use crate::ResistanceType;
278
279 #[test]
280 fn effort_kind_round_trip() {
281 for k in [EffortKind::Reps, EffortKind::Timed, EffortKind::Distance] {
282 assert_eq!(EffortKind::parse(k.as_str()).unwrap(), k);
283 }
284 }
285
286 #[test]
287 fn effort_kind_parse_rejects_unknown() {
288 assert!(EffortKind::parse("velocity").is_err());
289 }
290
291 #[test]
292 fn append_any_dispatches_to_reps_kind() {
293 let db = Db::open_in_memory().unwrap();
294 db.init_profile("self", LoadUnit::Kg).unwrap();
295 db.create_exercise(
296 "row",
297 ResistanceType::Freeweight,
298 LoadUnit::Kg,
299 2.5,
300 &[],
301 )
302 .unwrap();
303 let ex = db.list_exercises().unwrap().into_iter().next().unwrap();
304 let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
305 append_any(
306 &db,
307 &ex,
308 date,
309 AnyPayload::Reps(reps::RepsPayload::new(
310 Load::new(80.0).unwrap(),
311 Reps::new(5).unwrap(),
312 )),
313 Rpe::new(3).unwrap(),
314 false,
315 )
316 .unwrap();
317 let sessions = reps::RepsKind::list_sessions_for_exercise(&db, ex.id).unwrap();
318 assert_eq!(sessions.len(), 1);
319 assert_eq!(sessions[0][0].load.get(), 80.0);
320 }
321
322 #[test]
323 fn append_any_rejects_wrong_kind_for_exercise() {
324 // Nothing but Reps is wired up yet, so the mismatch case here
325 // requires poking a mismatched Exercise. A CardioDistance
326 // exercise's effort_kind is Distance, but we hand it a Reps
327 // payload — the dispatcher should refuse rather than corrupt a
328 // table.
329 let db = Db::open_in_memory().unwrap();
330 db.init_profile("self", LoadUnit::Kg).unwrap();
331 db.create_exercise(
332 "run",
333 ResistanceType::CardioDistance,
334 LoadUnit::Kg,
335 0.0,
336 &[],
337 )
338 .unwrap();
339 let ex = db.list_exercises().unwrap().into_iter().next().unwrap();
340 let err = append_any(
341 &db,
342 &ex,
343 chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
344 AnyPayload::Reps(reps::RepsPayload::new(
345 Load::new(80.0).unwrap(),
346 Reps::new(5).unwrap(),
347 )),
348 Rpe::new(3).unwrap(),
349 false,
350 );
351 assert!(matches!(err, Err(Error::EffortKindMismatch { .. })));
352 }
353 }
354