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