//! Per-kind effort layer. //! //! Phase 4 splits the single `sets` table into one table per effort kind //! (`reps_sets`, `timed_sets`, `distance_sets`). Each kind gets its own //! typed row shape, its own SQL, and its own protocol logic; the shared //! state machine reasons purely in terms of `SessionOutcome` and doesn't //! care what the numbers represent. //! //! The [`Kind`] trait is the contract every kind implements. It carries //! associated types (`Payload`, `Set`, `Prescription`) plus a fixed set //! of DB, evaluation, and prescription methods, so adding a new kind is //! a compile-time-verified list of "things to implement." //! //! Callsites that need to work across kinds dispatch on //! [`EffortKind`] and pack the results into [`AnyPayload`] / //! [`AnySet`] / [`AnyPrescription`] enums. Inside a kind's module //! everything is strongly typed. use chrono::NaiveDate; use crate::db::Db; use crate::error::Error; use crate::progression::SessionOutcome; use crate::templates::Exercise; use crate::values::Rpe; pub mod distance; pub mod reps; pub mod timed; /// Discriminant carried on every exercise. Stored as text /// (`'reps'` / `'timed'` / `'distance'`) in `exercises.effort_kind`, /// which decides which per-kind sets table holds that exercise's history. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EffortKind { Reps, Timed, Distance, } impl EffortKind { pub fn as_str(&self) -> &'static str { match self { EffortKind::Reps => "reps", EffortKind::Timed => "timed", EffortKind::Distance => "distance", } } pub fn parse(s: &str) -> Result { match s { "reps" => Ok(EffortKind::Reps), "timed" => Ok(EffortKind::Timed), "distance" => Ok(EffortKind::Distance), other => Err(Error::InvalidEffortKind(other.to_string())), } } } /// A completed session's result from the state machine's point of view. /// Renamed here for clarity — the shared state machine takes this from /// every kind's `evaluate` method. pub type Outcome = SessionOutcome; /// Generic version of the reps-only `PrescriptionResult`. Every kind /// returns its own `P`. #[derive(Debug, Clone, PartialEq)] pub enum PrescriptionResult

{ /// No sessions logged for this exercise yet under this kind's /// working history. UI prompts for a first-log seed. NoHistory, Prescribed(P), } /// Contract every effort kind implements. The associated types let each /// kind carry its own payload / stored-set / prescription shape without /// leaking into shared code. Callers that stay inside one kind speak /// the associated types directly; callers that mix kinds use the /// [`Any*`](AnyPayload) enums at the boundary. pub trait Kind: 'static { /// Kind-specific input for inserting a set. Load + reps for reps; /// duration for timed; distance + duration for distance. RPE and the /// diagnostic flag live outside `Payload` because they're universal. type Payload: Clone + PartialEq + Send + Sync; /// Fully-decoded row from this kind's sets table. Includes the /// payload plus shared metadata (id, session_date, exercise_id, /// set_index, is_diagnostic, rpe). type Set: Clone + PartialEq + Send + Sync; /// The prescription this kind produces from history. type Prescription: Clone + PartialEq + Send + Sync; /// Discriminant this kind maps to. Must match the `effort_kind` /// column of every exercise that routes through this impl. const DISCRIMINANT: EffortKind; /// Name of the SQL table backing this kind (`reps_sets`, etc.). const TABLE: &'static str; // ---- persistence --------------------------------------------------- /// Append a set. The `is_diagnostic` flag decides which downstream /// queries the row participates in; see the per-kind list functions /// for the filtering rules. fn append( db: &Db, exercise_id: i64, date: NaiveDate, payload: Self::Payload, rpe: Rpe, is_diagnostic: bool, ) -> Result; /// Every set for `exercise_id` on `date`, in set-index order. /// Includes diagnostic sets (the log screen shows every row that /// was actually recorded). fn list_sets_for_session( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Result, Error>; /// Every non-diagnostic session for `exercise_id`, oldest first, /// grouped by date. Diagnostic sets are filtered so the progression /// state machine only walks real working sessions. fn list_sessions_for_exercise( db: &Db, exercise_id: i64, ) -> Result>, Error>; /// Delete the most recent diagnostic set for `exercise_id` on /// `date`. No-op if there isn't one. fn delete_last_diagnostic_on_date( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Result<(), Error>; // ---- state machine / prescription --------------------------------- /// Reduce a single session's sets to a [`SessionOutcome`] the shared /// state machine can process. Each kind decides what "target hit" /// means for its payload shape. fn evaluate(sets: &[Self::Set]) -> Outcome; /// Compute the next prescription from full history + exercise-level /// config (increment, etc.). Handles the diagnostic-seed fallback /// for kinds that support it. fn compute_prescription( db: &Db, exercise: &Exercise, ) -> Result, Error>; } // ---- runtime-typed wrappers ------------------------------------------------ /// Untyped payload for insertion at a dispatch boundary. Callers that /// have parsed a payload from user input use this to hand off to the /// per-kind append via [`append_any`]. #[derive(Debug, Clone, PartialEq)] pub enum AnyPayload { Reps(::Payload), Timed(::Payload), Distance(::Payload), } impl AnyPayload { pub fn kind(&self) -> EffortKind { match self { AnyPayload::Reps(_) => EffortKind::Reps, AnyPayload::Timed(_) => EffortKind::Timed, AnyPayload::Distance(_) => EffortKind::Distance, } } } /// Untyped stored-set for reads that cross kinds (rare — most reads /// stay inside one kind). Useful for the log screen when we render a /// mixed-kind history view. #[derive(Debug, Clone, PartialEq)] pub enum AnySet { Reps(::Set), Timed(::Set), Distance(::Set), } impl AnySet { pub fn kind(&self) -> EffortKind { match self { AnySet::Reps(_) => EffortKind::Reps, AnySet::Timed(_) => EffortKind::Timed, AnySet::Distance(_) => EffortKind::Distance, } } } /// Untyped prescription. Every callsite that displays a "next session" /// number ends up unpacking this to route through kind-specific /// rendering (`format_prescription`). #[derive(Debug, Clone, PartialEq)] pub enum AnyPrescription { Reps(::Prescription), Timed(::Prescription), Distance(::Prescription), } impl AnyPrescription { pub fn kind(&self) -> EffortKind { match self { AnyPrescription::Reps(_) => EffortKind::Reps, AnyPrescription::Timed(_) => EffortKind::Timed, AnyPrescription::Distance(_) => EffortKind::Distance, } } } /// Dispatch: hand a parsed [`AnyPayload`] to the right kind's `append`. pub fn append_any( db: &Db, exercise: &Exercise, date: NaiveDate, payload: AnyPayload, rpe: Rpe, is_diagnostic: bool, ) -> Result { if payload.kind() != exercise.effort_kind { return Err(Error::EffortKindMismatch { exercise: exercise.effort_kind.as_str(), payload: payload.kind().as_str(), }); } match payload { AnyPayload::Reps(p) => { reps::RepsKind::append(db, exercise.id, date, p, rpe, is_diagnostic) } AnyPayload::Timed(p) => { timed::TimedKind::append(db, exercise.id, date, p, rpe, is_diagnostic) } AnyPayload::Distance(p) => { distance::DistanceKind::append(db, exercise.id, date, p, rpe, is_diagnostic) } } } /// Dispatch: compute a prescription for the exercise regardless of kind. /// Distance kinds always return `NoHistory` today (see /// [`distance`](self::distance) module docs). pub fn compute_prescription_any( db: &Db, exercise: &Exercise, ) -> Result, Error> { match exercise.effort_kind { EffortKind::Reps => match reps::RepsKind::compute_prescription(db, exercise)? { PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory), PrescriptionResult::Prescribed(p) => { Ok(PrescriptionResult::Prescribed(AnyPrescription::Reps(p))) } }, EffortKind::Timed => match timed::TimedKind::compute_prescription(db, exercise)? { PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory), PrescriptionResult::Prescribed(p) => { Ok(PrescriptionResult::Prescribed(AnyPrescription::Timed(p))) } }, EffortKind::Distance => match distance::DistanceKind::compute_prescription(db, exercise)? { PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory), PrescriptionResult::Prescribed(p) => Ok(PrescriptionResult::Prescribed( AnyPrescription::Distance(p), )), }, } } #[cfg(test)] mod tests { use super::*; use crate::values::{Load, LoadUnit, Reps}; use crate::ResistanceType; #[test] fn effort_kind_round_trip() { for k in [EffortKind::Reps, EffortKind::Timed, EffortKind::Distance] { assert_eq!(EffortKind::parse(k.as_str()).unwrap(), k); } } #[test] fn effort_kind_parse_rejects_unknown() { assert!(EffortKind::parse("velocity").is_err()); } #[test] fn append_any_dispatches_to_reps_kind() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); db.create_exercise( "row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[], ) .unwrap(); let ex = db.list_exercises().unwrap().into_iter().next().unwrap(); let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); append_any( &db, &ex, date, AnyPayload::Reps(reps::RepsPayload::new( Load::new(80.0).unwrap(), Reps::new(5).unwrap(), )), Rpe::new(3).unwrap(), false, ) .unwrap(); let sessions = reps::RepsKind::list_sessions_for_exercise(&db, ex.id).unwrap(); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0][0].load.get(), 80.0); } #[test] fn append_any_rejects_wrong_kind_for_exercise() { // Nothing but Reps is wired up yet, so the mismatch case here // requires poking a mismatched Exercise. A CardioDistance // exercise's effort_kind is Distance, but we hand it a Reps // payload — the dispatcher should refuse rather than corrupt a // table. let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); db.create_exercise( "run", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[], ) .unwrap(); let ex = db.list_exercises().unwrap().into_iter().next().unwrap(); let err = append_any( &db, &ex, chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(), AnyPayload::Reps(reps::RepsPayload::new( Load::new(80.0).unwrap(), Reps::new(5).unwrap(), )), Rpe::new(3).unwrap(), false, ); assert!(matches!(err, Err(Error::EffortKindMismatch { .. }))); } }