max / ripgrow
5 files changed,
+955 insertions,
-3 deletions
| @@ -0,0 +1,420 @@ | |||
| 1 | + | //! Exercise scorer + greedy session picker. | |
| 2 | + | //! | |
| 3 | + | //! Given a set of exercise templates, per-tag readiness, and how recently | |
| 4 | + | //! each exercise was last done, produce a session of `count` picks plus a | |
| 5 | + | //! ranked alternates list per slot. The scorer is deliberately tunable and | |
| 6 | + | //! transparent so the reroll UX can explain "why is X here." | |
| 7 | + | //! | |
| 8 | + | //! Rules per the design note: | |
| 9 | + | //! | |
| 10 | + | //! - `Ready` tag contributes +1.0 to any exercise carrying that tag. | |
| 11 | + | //! - `Tired` tag contributes -1.0. | |
| 12 | + | //! - `Sore` tag is a hard block: exercises carrying that tag are removed | |
| 13 | + | //! from the pool entirely. | |
| 14 | + | //! - Recency bonus: `days_since_last / 7.0`, capped at 3.0. Never-done | |
| 15 | + | //! exercises get the cap, so novelty is preferred over stale rotations. | |
| 16 | + | //! - Diminishing returns: after an exercise is picked, its tags gain 1.0 | |
| 17 | + | //! of "saturation". Each contributing tag is scaled by `1 / (1 + sat)` | |
| 18 | + | //! for subsequent picks, so the greedy pass fans out rather than | |
| 19 | + | //! stacking on one muscle group. | |
| 20 | + | ||
| 21 | + | use std::collections::HashMap; | |
| 22 | + | ||
| 23 | + | use crate::templates::Exercise; | |
| 24 | + | ||
| 25 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 26 | + | pub enum Readiness { | |
| 27 | + | Ready, | |
| 28 | + | Tired, | |
| 29 | + | Sore, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl Readiness { | |
| 33 | + | pub fn as_str(&self) -> &'static str { | |
| 34 | + | match self { | |
| 35 | + | Readiness::Ready => "ready", | |
| 36 | + | Readiness::Tired => "tired", | |
| 37 | + | Readiness::Sore => "sore", | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | pub fn parse(s: &str) -> Option<Self> { | |
| 42 | + | match s { | |
| 43 | + | "ready" => Some(Readiness::Ready), | |
| 44 | + | "tired" => Some(Readiness::Tired), | |
| 45 | + | "sore" => Some(Readiness::Sore), | |
| 46 | + | _ => None, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// One slot in the generated session: a primary pick plus alternates, | |
| 52 | + | /// ranked by score under the same saturation state that produced the pick. | |
| 53 | + | #[derive(Debug, Clone, PartialEq)] | |
| 54 | + | pub struct PickedSlot { | |
| 55 | + | pub primary: Exercise, | |
| 56 | + | pub alternates: Vec<Exercise>, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Score a single exercise. Returns `None` when any of its tags is marked | |
| 60 | + | /// `Sore` (hard block). | |
| 61 | + | pub fn score( | |
| 62 | + | exercise: &Exercise, | |
| 63 | + | readiness: &HashMap<i64, Readiness>, | |
| 64 | + | days_since_last: Option<i64>, | |
| 65 | + | tag_saturation: &HashMap<i64, f64>, | |
| 66 | + | ) -> Option<f64> { | |
| 67 | + | let mut total = 0.0; | |
| 68 | + | for tid in &exercise.tag_ids { | |
| 69 | + | match readiness.get(tid) { | |
| 70 | + | Some(Readiness::Sore) => return None, | |
| 71 | + | Some(r) => { | |
| 72 | + | let base = match r { | |
| 73 | + | Readiness::Ready => 1.0, | |
| 74 | + | Readiness::Tired => -1.0, | |
| 75 | + | Readiness::Sore => unreachable!(), | |
| 76 | + | }; | |
| 77 | + | let sat = tag_saturation.get(tid).copied().unwrap_or(0.0); | |
| 78 | + | total += base / (1.0 + sat); | |
| 79 | + | } | |
| 80 | + | None => {} | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | let recency = match days_since_last { | |
| 84 | + | Some(d) => ((d as f64) / 7.0).min(3.0), | |
| 85 | + | None => 3.0, | |
| 86 | + | }; | |
| 87 | + | Some(total + recency) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Pick a full session of `count` exercises. | |
| 91 | + | /// | |
| 92 | + | /// Uses the greedy strategy from the design: on each iteration, score every | |
| 93 | + | /// unpicked exercise under the current saturation, take the top scorer as | |
| 94 | + | /// the primary for the slot, record the next `alternates_per_slot` as | |
| 95 | + | /// alternates, then bump saturation for the picked exercise's tags. | |
| 96 | + | pub fn pick_session( | |
| 97 | + | exercises: &[Exercise], | |
| 98 | + | readiness: &HashMap<i64, Readiness>, | |
| 99 | + | days_since_last: &HashMap<i64, i64>, | |
| 100 | + | count: usize, | |
| 101 | + | alternates_per_slot: usize, | |
| 102 | + | ) -> Vec<PickedSlot> { | |
| 103 | + | let mut picks: Vec<PickedSlot> = Vec::new(); | |
| 104 | + | let mut saturation: HashMap<i64, f64> = HashMap::new(); | |
| 105 | + | let mut picked_ids: std::collections::HashSet<i64> = std::collections::HashSet::new(); | |
| 106 | + | ||
| 107 | + | for _ in 0..count { | |
| 108 | + | // Score every candidate. Sore tags exclude entirely; already-picked | |
| 109 | + | // exercises are also excluded. | |
| 110 | + | let mut scored: Vec<(f64, &Exercise)> = exercises | |
| 111 | + | .iter() | |
| 112 | + | .filter(|e| !picked_ids.contains(&e.id)) | |
| 113 | + | .filter_map(|e| { | |
| 114 | + | let s = score(e, readiness, days_since_last.get(&e.id).copied(), &saturation)?; | |
| 115 | + | Some((s, e)) | |
| 116 | + | }) | |
| 117 | + | .collect(); | |
| 118 | + | ||
| 119 | + | if scored.is_empty() { | |
| 120 | + | break; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Descending score; ties broken by name for determinism. | |
| 124 | + | scored.sort_by(|a, b| { | |
| 125 | + | b.0.partial_cmp(&a.0) | |
| 126 | + | .unwrap_or(std::cmp::Ordering::Equal) | |
| 127 | + | .then_with(|| a.1.name.cmp(&b.1.name)) | |
| 128 | + | }); | |
| 129 | + | ||
| 130 | + | let primary = scored[0].1.clone(); | |
| 131 | + | let alternates: Vec<Exercise> = scored | |
| 132 | + | .iter() | |
| 133 | + | .skip(1) | |
| 134 | + | .take(alternates_per_slot) | |
| 135 | + | .map(|(_, e)| (*e).clone()) | |
| 136 | + | .collect(); | |
| 137 | + | ||
| 138 | + | for tid in &primary.tag_ids { | |
| 139 | + | *saturation.entry(*tid).or_insert(0.0) += 1.0; | |
| 140 | + | } | |
| 141 | + | picked_ids.insert(primary.id); | |
| 142 | + | picks.push(PickedSlot { primary, alternates }); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | picks | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// Union of tags across the day's picks, ordered by how many picks each | |
| 149 | + | /// tag appears in (descending). Used for the printed warmup line. | |
| 150 | + | pub fn warmup_tag_ids(picks: &[PickedSlot]) -> Vec<i64> { | |
| 151 | + | let mut counts: HashMap<i64, i32> = HashMap::new(); | |
| 152 | + | for slot in picks { | |
| 153 | + | for tid in &slot.primary.tag_ids { | |
| 154 | + | *counts.entry(*tid).or_insert(0) += 1; | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | let mut pairs: Vec<(i64, i32)> = counts.into_iter().collect(); | |
| 158 | + | pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); | |
| 159 | + | pairs.into_iter().map(|(id, _)| id).collect() | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // -- DB glue ----------------------------------------------------------------- | |
| 163 | + | ||
| 164 | + | use crate::db::Db; | |
| 165 | + | use crate::error::Error; | |
| 166 | + | use chrono::NaiveDate; | |
| 167 | + | use rusqlite::params; | |
| 168 | + | ||
| 169 | + | impl Db { | |
| 170 | + | /// Read a HashMap of `tag_id -> Readiness` for the given date. | |
| 171 | + | /// Absent tags are simply not in the map. | |
| 172 | + | pub fn read_readiness(&self, date: NaiveDate) -> Result<HashMap<i64, Readiness>, Error> { | |
| 173 | + | let mut stmt = self | |
| 174 | + | .conn() | |
| 175 | + | .prepare("SELECT tag_id, state FROM readiness WHERE date = ?1")?; | |
| 176 | + | let rows = stmt.query_map(params![date.to_string()], |row| { | |
| 177 | + | let s: String = row.get(1)?; | |
| 178 | + | Ok((row.get::<_, i64>(0)?, s)) | |
| 179 | + | })?; | |
| 180 | + | let mut out = HashMap::new(); | |
| 181 | + | for row in rows { | |
| 182 | + | let (tag_id, s) = row?; | |
| 183 | + | if let Some(r) = Readiness::parse(&s) { | |
| 184 | + | out.insert(tag_id, r); | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | Ok(out) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Set a tag's readiness for `date`. Pass `None` to clear. | |
| 191 | + | pub fn set_readiness( | |
| 192 | + | &self, | |
| 193 | + | date: NaiveDate, | |
| 194 | + | tag_id: i64, | |
| 195 | + | readiness: Option<Readiness>, | |
| 196 | + | ) -> Result<(), Error> { | |
| 197 | + | match readiness { | |
| 198 | + | Some(r) => { | |
| 199 | + | self.conn().execute( | |
| 200 | + | "INSERT INTO readiness (date, tag_id, state) VALUES (?1, ?2, ?3) \ | |
| 201 | + | ON CONFLICT(date, tag_id) DO UPDATE SET state = excluded.state", | |
| 202 | + | params![date.to_string(), tag_id, r.as_str()], | |
| 203 | + | )?; | |
| 204 | + | } | |
| 205 | + | None => { | |
| 206 | + | self.conn().execute( | |
| 207 | + | "DELETE FROM readiness WHERE date = ?1 AND tag_id = ?2", | |
| 208 | + | params![date.to_string(), tag_id], | |
| 209 | + | )?; | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | Ok(()) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// For every exercise with logged sets, the number of days between | |
| 216 | + | /// its most-recent session and `today`. Exercises never logged are | |
| 217 | + | /// not in the map. | |
| 218 | + | pub fn days_since_last_map( | |
| 219 | + | &self, | |
| 220 | + | today: NaiveDate, | |
| 221 | + | ) -> Result<HashMap<i64, i64>, Error> { | |
| 222 | + | let mut stmt = self.conn().prepare( | |
| 223 | + | "SELECT exercise_id, MAX(session_date) FROM sets GROUP BY exercise_id", | |
| 224 | + | )?; | |
| 225 | + | let rows = stmt.query_map([], |row| { | |
| 226 | + | let id: i64 = row.get(0)?; | |
| 227 | + | let date_str: String = row.get(1)?; | |
| 228 | + | Ok((id, date_str)) | |
| 229 | + | })?; | |
| 230 | + | let mut out = HashMap::new(); | |
| 231 | + | for row in rows { | |
| 232 | + | let (id, date_str) = row?; | |
| 233 | + | if let Ok(d) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | |
| 234 | + | let days = (today - d).num_days().max(0); | |
| 235 | + | out.insert(id, days); | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | Ok(out) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[cfg(test)] | |
| 243 | + | mod tests { | |
| 244 | + | use super::*; | |
| 245 | + | use crate::db::Unit; | |
| 246 | + | use crate::templates::ResistanceType; | |
| 247 | + | ||
| 248 | + | fn ex(id: i64, name: &str, tags: Vec<i64>) -> Exercise { | |
| 249 | + | Exercise { | |
| 250 | + | id, | |
| 251 | + | name: name.to_string(), | |
| 252 | + | resistance_type: ResistanceType::Freeweight, | |
| 253 | + | load_unit: "kg".to_string(), | |
| 254 | + | increment: 2.5, | |
| 255 | + | notes: String::new(), | |
| 256 | + | tag_ids: tags, | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | #[test] | |
| 261 | + | fn score_sore_returns_none() { | |
| 262 | + | let e = ex(1, "bench", vec![10]); | |
| 263 | + | let readiness: HashMap<_, _> = [(10, Readiness::Sore)].into_iter().collect(); | |
| 264 | + | assert_eq!(score(&e, &readiness, Some(2), &HashMap::new()), None); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn score_ready_beats_tired() { | |
| 269 | + | let ready = ex(1, "a", vec![10]); | |
| 270 | + | let tired = ex(2, "b", vec![20]); | |
| 271 | + | let readiness: HashMap<_, _> = [(10, Readiness::Ready), (20, Readiness::Tired)] | |
| 272 | + | .into_iter() | |
| 273 | + | .collect(); | |
| 274 | + | let sa = score(&ready, &readiness, Some(2), &HashMap::new()).unwrap(); | |
| 275 | + | let sb = score(&tired, &readiness, Some(2), &HashMap::new()).unwrap(); | |
| 276 | + | assert!(sa > sb); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[test] | |
| 280 | + | fn recency_bonus_prefers_less_recently_done() { | |
| 281 | + | let stale = ex(1, "a", vec![10]); | |
| 282 | + | let fresh = ex(2, "b", vec![10]); | |
| 283 | + | let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect(); | |
| 284 | + | let s_stale = score(&stale, &readiness, Some(14), &HashMap::new()).unwrap(); | |
| 285 | + | let s_fresh = score(&fresh, &readiness, Some(0), &HashMap::new()).unwrap(); | |
| 286 | + | assert!(s_stale > s_fresh); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn never_done_gets_full_recency_bonus() { | |
| 291 | + | let e = ex(1, "a", vec![10]); | |
| 292 | + | let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect(); | |
| 293 | + | let s = score(&e, &readiness, None, &HashMap::new()).unwrap(); | |
| 294 | + | // 1.0 (ready) + 3.0 (max recency) = 4.0. | |
| 295 | + | assert!((s - 4.0).abs() < 1e-9); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | #[test] | |
| 299 | + | fn diminishing_returns_deprioritizes_saturated_tags() { | |
| 300 | + | let e = ex(1, "a", vec![10]); | |
| 301 | + | let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect(); | |
| 302 | + | let no_sat = score(&e, &readiness, Some(0), &HashMap::new()).unwrap(); | |
| 303 | + | let sat: HashMap<_, _> = [(10, 1.0)].into_iter().collect(); | |
| 304 | + | let with_sat = score(&e, &readiness, Some(0), &sat).unwrap(); | |
| 305 | + | assert!(with_sat < no_sat); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | #[test] | |
| 309 | + | fn picker_fans_out_across_tags() { | |
| 310 | + | // 3 exercises: chest+triceps, back+biceps, legs+core. Two picks | |
| 311 | + | // should hit disjoint tag groups. | |
| 312 | + | let readiness: HashMap<_, _> = [1, 2, 3, 4, 5, 6] | |
| 313 | + | .into_iter() | |
| 314 | + | .map(|t| (t, Readiness::Ready)) | |
| 315 | + | .collect(); | |
| 316 | + | let exercises = vec![ | |
| 317 | + | ex(1, "bench", vec![1, 2]), | |
| 318 | + | ex(2, "row", vec![3, 4]), | |
| 319 | + | ex(3, "squat", vec![5, 6]), | |
| 320 | + | ]; | |
| 321 | + | let picks = pick_session(&exercises, &readiness, &HashMap::new(), 2, 3); | |
| 322 | + | assert_eq!(picks.len(), 2); | |
| 323 | + | let ids: Vec<i64> = picks.iter().map(|p| p.primary.id).collect(); | |
| 324 | + | assert_ne!(ids[0], ids[1]); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn picker_skips_sore_exercises() { | |
| 329 | + | let readiness: HashMap<_, _> = [ | |
| 330 | + | (1, Readiness::Ready), | |
| 331 | + | (2, Readiness::Sore), | |
| 332 | + | (3, Readiness::Ready), | |
| 333 | + | ] | |
| 334 | + | .into_iter() | |
| 335 | + | .collect(); | |
| 336 | + | let exercises = vec![ | |
| 337 | + | ex(1, "bench", vec![1, 2]), // blocked (has sore tag) | |
| 338 | + | ex(2, "row", vec![3]), | |
| 339 | + | ]; | |
| 340 | + | let picks = pick_session(&exercises, &readiness, &HashMap::new(), 2, 3); | |
| 341 | + | assert_eq!(picks.len(), 1); | |
| 342 | + | assert_eq!(picks[0].primary.id, 2); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn alternates_are_ranked_and_exclude_primary() { | |
| 347 | + | let readiness: HashMap<_, _> = | |
| 348 | + | [1, 2, 3].into_iter().map(|t| (t, Readiness::Ready)).collect(); | |
| 349 | + | let exercises = vec![ | |
| 350 | + | ex(1, "a", vec![1]), | |
| 351 | + | ex(2, "b", vec![2]), | |
| 352 | + | ex(3, "c", vec![3]), | |
| 353 | + | ]; | |
| 354 | + | let picks = pick_session(&exercises, &readiness, &HashMap::new(), 1, 5); | |
| 355 | + | assert_eq!(picks.len(), 1); | |
| 356 | + | assert!(!picks[0].alternates.iter().any(|e| e.id == picks[0].primary.id)); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn warmup_tags_ordered_by_frequency() { | |
| 361 | + | let picks = vec![ | |
| 362 | + | PickedSlot { | |
| 363 | + | primary: ex(1, "a", vec![10, 20]), | |
| 364 | + | alternates: vec![], | |
| 365 | + | }, | |
| 366 | + | PickedSlot { | |
| 367 | + | primary: ex(2, "b", vec![10, 30]), | |
| 368 | + | alternates: vec![], | |
| 369 | + | }, | |
| 370 | + | ]; | |
| 371 | + | let tags = warmup_tag_ids(&picks); | |
| 372 | + | assert_eq!(tags[0], 10, "tag 10 appears in both picks"); | |
| 373 | + | assert!(tags.contains(&20) && tags.contains(&30)); | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // -- DB glue tests -------------------------------------------------- | |
| 377 | + | ||
| 378 | + | fn setup() -> Db { | |
| 379 | + | let db = Db::open_in_memory().unwrap(); | |
| 380 | + | db.init_profile("self", Unit::Kg).unwrap(); | |
| 381 | + | db | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | #[test] | |
| 385 | + | fn readiness_round_trip_and_clear() { | |
| 386 | + | let db = setup(); | |
| 387 | + | let t = db.upsert_tag("chest").unwrap(); | |
| 388 | + | let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); | |
| 389 | + | db.set_readiness(date, t.id, Some(Readiness::Ready)).unwrap(); | |
| 390 | + | assert_eq!( | |
| 391 | + | db.read_readiness(date).unwrap().get(&t.id), | |
| 392 | + | Some(&Readiness::Ready) | |
| 393 | + | ); | |
| 394 | + | db.set_readiness(date, t.id, Some(Readiness::Tired)).unwrap(); | |
| 395 | + | assert_eq!( | |
| 396 | + | db.read_readiness(date).unwrap().get(&t.id), | |
| 397 | + | Some(&Readiness::Tired) | |
| 398 | + | ); | |
| 399 | + | db.set_readiness(date, t.id, None).unwrap(); | |
| 400 | + | assert_eq!(db.read_readiness(date).unwrap().get(&t.id), None); | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | #[test] | |
| 404 | + | fn days_since_last_map_populated_only_for_logged_exercises() { | |
| 405 | + | let db = setup(); | |
| 406 | + | let a = db | |
| 407 | + | .create_exercise("a", ResistanceType::Freeweight, "kg", 2.5, &[]) | |
| 408 | + | .unwrap(); | |
| 409 | + | let _b = db | |
| 410 | + | .create_exercise("b", ResistanceType::Freeweight, "kg", 2.5, &[]) | |
| 411 | + | .unwrap(); | |
| 412 | + | let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); | |
| 413 | + | db.append_set(a, d, 100.0, 5, 3).unwrap(); | |
| 414 | + | let map = db | |
| 415 | + | .days_since_last_map(NaiveDate::from_ymd_opt(2026, 7, 15).unwrap()) | |
| 416 | + | .unwrap(); | |
| 417 | + | assert_eq!(map.get(&a).copied(), Some(5)); | |
| 418 | + | assert_eq!(map.get(&_b), None); | |
| 419 | + | } | |
| 420 | + | } |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | pub mod db; | |
| 7 | 7 | pub mod error; | |
| 8 | + | pub mod generation; | |
| 8 | 9 | pub mod profiles; | |
| 9 | 10 | pub mod progression; | |
| 10 | 11 | pub mod sets; | |
| @@ -12,6 +13,7 @@ pub mod templates; | |||
| 12 | 13 | ||
| 13 | 14 | pub use db::{Db, Unit}; | |
| 14 | 15 | pub use error::Error; | |
| 16 | + | pub use generation::{PickedSlot, Readiness}; | |
| 15 | 17 | pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify}; | |
| 16 | 18 | pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State}; | |
| 17 | 19 | pub use sets::SessionSet; |
| @@ -14,6 +14,7 @@ use ratatui::text::{Line, Span}; | |||
| 14 | 14 | use ratatui::widgets::{Block, Borders, Tabs}; | |
| 15 | 15 | use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir}; | |
| 16 | 16 | ||
| 17 | + | use crate::screens::generate::GenerateScreen; | |
| 17 | 18 | use crate::screens::log::LogScreen; | |
| 18 | 19 | use crate::screens::profile_picker::{PickerAction, ProfilePicker}; | |
| 19 | 20 | use crate::screens::templates::{TemplatesAction, TemplatesScreen}; | |
| @@ -31,21 +32,24 @@ struct Opened { | |||
| 31 | 32 | tab: Tab, | |
| 32 | 33 | templates: TemplatesScreen, | |
| 33 | 34 | log: LogScreen, | |
| 35 | + | generate: GenerateScreen, | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 37 | 39 | enum Tab { | |
| 38 | 40 | Templates, | |
| 39 | 41 | Log, | |
| 42 | + | Generate, | |
| 40 | 43 | } | |
| 41 | 44 | ||
| 42 | 45 | impl Tab { | |
| 43 | - | const ALL: [Tab; 2] = [Tab::Templates, Tab::Log]; | |
| 46 | + | const ALL: [Tab; 3] = [Tab::Templates, Tab::Log, Tab::Generate]; | |
| 44 | 47 | ||
| 45 | 48 | fn title(self) -> &'static str { | |
| 46 | 49 | match self { | |
| 47 | 50 | Tab::Templates => "templates", | |
| 48 | 51 | Tab::Log => "log", | |
| 52 | + | Tab::Generate => "generate", | |
| 49 | 53 | } | |
| 50 | 54 | } | |
| 51 | 55 | } | |
| @@ -98,6 +102,7 @@ impl App { | |||
| 98 | 102 | match op.tab { | |
| 99 | 103 | Tab::Templates => op.templates.render(frame, chunks[2]), | |
| 100 | 104 | Tab::Log => op.log.render(frame, chunks[2]), | |
| 105 | + | Tab::Generate => op.generate.render(frame, chunks[2], &op.db), | |
| 101 | 106 | } | |
| 102 | 107 | } | |
| 103 | 108 | None => { | |
| @@ -130,6 +135,10 @@ impl App { | |||
| 130 | 135 | op.tab = Tab::Log; | |
| 131 | 136 | return; | |
| 132 | 137 | } | |
| 138 | + | KeyCode::Char('3') => { | |
| 139 | + | op.tab = Tab::Generate; | |
| 140 | + | return; | |
| 141 | + | } | |
| 133 | 142 | _ => {} | |
| 134 | 143 | } | |
| 135 | 144 | } | |
| @@ -143,17 +152,21 @@ impl App { | |||
| 143 | 152 | TemplatesAction::None => {} | |
| 144 | 153 | TemplatesAction::Quit => self.should_quit = true, | |
| 145 | 154 | TemplatesAction::Refresh => { | |
| 146 | - | // Reload both screens so newly-created templates | |
| 147 | - | // appear in the log picker without extra plumbing. | |
| 155 | + | // Reload downstream screens so newly-created | |
| 156 | + | // templates and tags appear without extra plumbing. | |
| 148 | 157 | if let Ok(next) = TemplatesScreen::load(&op.db) { | |
| 149 | 158 | op.templates = next; | |
| 150 | 159 | } | |
| 151 | 160 | if let Ok(next) = LogScreen::load(&op.db) { | |
| 152 | 161 | op.log = next; | |
| 153 | 162 | } | |
| 163 | + | if let Ok(next) = GenerateScreen::load(&op.db) { | |
| 164 | + | op.generate = next; | |
| 165 | + | } | |
| 154 | 166 | } | |
| 155 | 167 | }, | |
| 156 | 168 | Tab::Log => op.log.on_key(&op.db, key), | |
| 169 | + | Tab::Generate => op.generate.on_key(&op.db, key), | |
| 157 | 170 | } | |
| 158 | 171 | return; | |
| 159 | 172 | } | |
| @@ -190,6 +203,13 @@ impl App { | |||
| 190 | 203 | return; | |
| 191 | 204 | } | |
| 192 | 205 | }; | |
| 206 | + | let generate = match GenerateScreen::load(&db) { | |
| 207 | + | Ok(s) => s, | |
| 208 | + | Err(e) => { | |
| 209 | + | self.status = format!("load generate failed: {e}"); | |
| 210 | + | return; | |
| 211 | + | } | |
| 212 | + | }; | |
| 193 | 213 | self.status = format!("opened {}", path.display()); | |
| 194 | 214 | self.opened = Some(Opened { | |
| 195 | 215 | path, | |
| @@ -197,6 +217,7 @@ impl App { | |||
| 197 | 217 | tab: Tab::Templates, | |
| 198 | 218 | templates, | |
| 199 | 219 | log, | |
| 220 | + | generate, | |
| 200 | 221 | }); | |
| 201 | 222 | } | |
| 202 | 223 | ||
| @@ -224,6 +245,13 @@ impl App { | |||
| 224 | 245 | return; | |
| 225 | 246 | } | |
| 226 | 247 | }; | |
| 248 | + | let generate = match GenerateScreen::load(&db) { | |
| 249 | + | Ok(s) => s, | |
| 250 | + | Err(e) => { | |
| 251 | + | self.status = format!("load generate failed: {e}"); | |
| 252 | + | return; | |
| 253 | + | } | |
| 254 | + | }; | |
| 227 | 255 | self.status = format!("created {}", profile.slug); | |
| 228 | 256 | self.opened = Some(Opened { | |
| 229 | 257 | path: profile.path, | |
| @@ -231,6 +259,7 @@ impl App { | |||
| 231 | 259 | tab: Tab::Templates, | |
| 232 | 260 | templates, | |
| 233 | 261 | log, | |
| 262 | + | generate, | |
| 234 | 263 | }); | |
| 235 | 264 | } | |
| 236 | 265 | Err(e) => self.status = format!("create failed: {e}"), |
| @@ -0,0 +1,579 @@ | |||
| 1 | + | //! Generate screen. Two sub-modes: | |
| 2 | + | //! | |
| 3 | + | //! - **Setup.** Pick session date, mark readiness per tag (unset/ready/ | |
| 4 | + | //! tired/sore), pick a target exercise count. `g` or Enter runs the | |
| 5 | + | //! picker. | |
| 6 | + | //! - **Preview.** Numbered slot list with the chosen exercise, the state- | |
| 7 | + | //! machine prescription, and a directional glyph versus the last session. | |
| 8 | + | //! Reroll keys (`r`, `R`, `d`, `a`) map to the design's UX; `e` is a PDF | |
| 9 | + | //! export stub until slice 6. | |
| 10 | + | ||
| 11 | + | use std::collections::HashMap; | |
| 12 | + | ||
| 13 | + | use chrono::{Duration, Local, NaiveDate}; | |
| 14 | + | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 15 | + | use ratatui::Frame; | |
| 16 | + | use ratatui::layout::{Constraint, Direction, Layout, Rect}; | |
| 17 | + | use ratatui::style::{Modifier, Style}; | |
| 18 | + | use ratatui::text::{Line, Span}; | |
| 19 | + | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; | |
| 20 | + | use ripgrow_core::generation::{PickedSlot, pick_session, score, warmup_tag_ids}; | |
| 21 | + | use ripgrow_core::{ | |
| 22 | + | Db, Exercise, Prescription, PrescriptionResult, Readiness, State, Tag, | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | pub struct GenerateScreen { | |
| 26 | + | date: NaiveDate, | |
| 27 | + | exercises: Vec<Exercise>, | |
| 28 | + | tags: Vec<Tag>, | |
| 29 | + | readiness: HashMap<i64, Readiness>, | |
| 30 | + | count: usize, | |
| 31 | + | setup_cursor: usize, | |
| 32 | + | preview: Option<Preview>, | |
| 33 | + | pub status: String, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | struct Preview { | |
| 37 | + | slots: Vec<SlotState>, | |
| 38 | + | slot_cursor: usize, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | struct SlotState { | |
| 42 | + | primary: Exercise, | |
| 43 | + | alternates: Vec<Exercise>, | |
| 44 | + | /// 0 = primary, 1..=alternates.len() indexes into `alternates`. | |
| 45 | + | idx: usize, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl SlotState { | |
| 49 | + | fn current(&self) -> &Exercise { | |
| 50 | + | if self.idx == 0 { | |
| 51 | + | &self.primary | |
| 52 | + | } else { | |
| 53 | + | &self.alternates[self.idx - 1] | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl GenerateScreen { | |
| 59 | + | pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> { | |
| 60 | + | let date = Local::now().date_naive(); | |
| 61 | + | Ok(Self { | |
| 62 | + | date, | |
| 63 | + | exercises: db.list_exercises()?, | |
| 64 | + | tags: db.list_tags()?, | |
| 65 | + | readiness: db.read_readiness(date)?, | |
| 66 | + | count: 4, | |
| 67 | + | setup_cursor: 0, | |
| 68 | + | preview: None, | |
| 69 | + | status: String::new(), | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | fn reload_readiness(&mut self, db: &Db) { | |
| 74 | + | if let Ok(r) = db.read_readiness(self.date) { | |
| 75 | + | self.readiness = r; | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | pub fn on_key(&mut self, db: &Db, key: KeyEvent) { | |
| 80 | + | if self.preview.is_some() { | |
| 81 | + | self.on_preview_key(db, key); | |
| 82 | + | } else { | |
| 83 | + | self.on_setup_key(db, key); | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // -- setup sub-mode --------------------------------------------------- | |
| 88 | + | ||
| 89 | + | fn on_setup_key(&mut self, db: &Db, key: KeyEvent) { | |
| 90 | + | match (key.code, key.modifiers) { | |
| 91 | + | (KeyCode::Char('['), KeyModifiers::NONE) => { | |
| 92 | + | self.date -= Duration::days(1); | |
| 93 | + | self.reload_readiness(db); | |
| 94 | + | } | |
| 95 | + | (KeyCode::Char(']'), KeyModifiers::NONE) => { | |
| 96 | + | self.date += Duration::days(1); | |
| 97 | + | self.reload_readiness(db); | |
| 98 | + | } | |
| 99 | + | (KeyCode::Char('+') | KeyCode::Char('='), _) => { | |
| 100 | + | self.count = (self.count + 1).min(20); | |
| 101 | + | } | |
| 102 | + | (KeyCode::Char('-') | KeyCode::Char('_'), _) => { | |
| 103 | + | self.count = self.count.saturating_sub(1).max(1); | |
| 104 | + | } | |
| 105 | + | (KeyCode::Down | KeyCode::Char('j'), _) => self.move_cursor(1), | |
| 106 | + | (KeyCode::Up | KeyCode::Char('k'), _) => self.move_cursor(-1), | |
| 107 | + | (KeyCode::Char(' '), _) => self.cycle_readiness(db), | |
| 108 | + | (KeyCode::Char('c'), KeyModifiers::NONE) => self.clear_readiness(db), | |
| 109 | + | (KeyCode::Char('g') | KeyCode::Enter, _) => self.generate(db), | |
| 110 | + | _ => {} | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | fn move_cursor(&mut self, delta: isize) { | |
| 115 | + | if self.tags.is_empty() { | |
| 116 | + | return; | |
| 117 | + | } | |
| 118 | + | let n = self.tags.len() as isize; | |
| 119 | + | let next = (self.setup_cursor as isize + delta).rem_euclid(n); | |
| 120 | + | self.setup_cursor = next as usize; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | fn cycle_readiness(&mut self, db: &Db) { | |
| 124 | + | let Some(tag) = self.tags.get(self.setup_cursor) else { | |
| 125 | + | return; | |
| 126 | + | }; | |
| 127 | + | let next = match self.readiness.get(&tag.id) { | |
| 128 | + | None => Some(Readiness::Ready), | |
| 129 | + | Some(Readiness::Ready) => Some(Readiness::Tired), | |
| 130 | + | Some(Readiness::Tired) => Some(Readiness::Sore), | |
| 131 | + | Some(Readiness::Sore) => None, | |
| 132 | + | }; | |
| 133 | + | if let Err(e) = db.set_readiness(self.date, tag.id, next) { | |
| 134 | + | self.status = format!("readiness save failed: {e}"); | |
| 135 | + | return; | |
| 136 | + | } | |
| 137 | + | match next { | |
| 138 | + | Some(r) => { | |
| 139 | + | self.readiness.insert(tag.id, r); | |
| 140 | + | } | |
| 141 | + | None => { | |
| 142 | + | self.readiness.remove(&tag.id); | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | fn clear_readiness(&mut self, db: &Db) { | |
| 148 | + | let Some(tag) = self.tags.get(self.setup_cursor) else { | |
| 149 | + | return; | |
| 150 | + | }; | |
| 151 | + | if let Err(e) = db.set_readiness(self.date, tag.id, None) { | |
| 152 | + | self.status = format!("clear failed: {e}"); | |
| 153 | + | return; | |
| 154 | + | } | |
| 155 | + | self.readiness.remove(&tag.id); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | fn generate(&mut self, db: &Db) { | |
| 159 | + | if self.exercises.is_empty() { | |
| 160 | + | self.status = "no templates yet — add some on the templates tab".to_string(); | |
| 161 | + | return; | |
| 162 | + | } | |
| 163 | + | let days = match db.days_since_last_map(self.date) { | |
| 164 | + | Ok(m) => m, | |
| 165 | + | Err(e) => { | |
| 166 | + | self.status = format!("recency lookup failed: {e}"); | |
| 167 | + | return; | |
| 168 | + | } | |
| 169 | + | }; | |
| 170 | + | let picks = pick_session(&self.exercises, &self.readiness, &days, self.count, 5); | |
| 171 | + | if picks.is_empty() { | |
| 172 | + | self.status = "no eligible exercises (check sore tags)".to_string(); | |
| 173 | + | return; | |
| 174 | + | } | |
| 175 | + | self.preview = Some(Preview { | |
| 176 | + | slots: picks | |
| 177 | + | .into_iter() | |
| 178 | + | .map(|p| SlotState { | |
| 179 | + | primary: p.primary, | |
| 180 | + | alternates: p.alternates, | |
| 181 | + | idx: 0, | |
| 182 | + | }) | |
| 183 | + | .collect(), | |
| 184 | + | slot_cursor: 0, | |
| 185 | + | }); | |
| 186 | + | self.status = "generated".to_string(); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // -- preview sub-mode ------------------------------------------------- | |
| 190 | + | ||
| 191 | + | fn on_preview_key(&mut self, db: &Db, key: KeyEvent) { | |
| 192 | + | let Some(p) = self.preview.as_mut() else { return }; | |
| 193 | + | match (key.code, key.modifiers) { | |
| 194 | + | (KeyCode::Esc, _) => { | |
| 195 | + | self.preview = None; | |
| 196 | + | } | |
| 197 | + | (KeyCode::Down | KeyCode::Char('j'), _) => { | |
| 198 | + | if !p.slots.is_empty() { | |
| 199 | + | p.slot_cursor = (p.slot_cursor + 1) % p.slots.len(); | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | (KeyCode::Up | KeyCode::Char('k'), _) => { | |
| 203 | + | if !p.slots.is_empty() { | |
| 204 | + | p.slot_cursor = | |
| 205 | + | (p.slot_cursor + p.slots.len() - 1) % p.slots.len(); | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | (KeyCode::Char('r'), KeyModifiers::NONE) => cycle_slot(p, 1), | |
| 209 | + | (KeyCode::Char('R'), _) | (KeyCode::Char('r'), KeyModifiers::SHIFT) => { | |
| 210 | + | cycle_slot(p, -1); | |
| 211 | + | } | |
| 212 | + | (KeyCode::Char('d'), _) => { | |
| 213 | + | if !p.slots.is_empty() { | |
| 214 | + | p.slots.remove(p.slot_cursor); | |
| 215 | + | if p.slot_cursor >= p.slots.len() && !p.slots.is_empty() { | |
| 216 | + | p.slot_cursor = p.slots.len() - 1; | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | (KeyCode::Char('a'), _) => self.add_slot(db), | |
| 221 | + | (KeyCode::Char('e'), _) => { | |
| 222 | + | self.status = "PDF export lands in the next slice".to_string(); | |
| 223 | + | } | |
| 224 | + | _ => {} | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | fn add_slot(&mut self, db: &Db) { | |
| 229 | + | let Some(p) = self.preview.as_mut() else { return }; | |
| 230 | + | let days = db.days_since_last_map(self.date).unwrap_or_default(); | |
| 231 | + | // Rebuild saturation from current selections so the added slot | |
| 232 | + | // respects the diminishing-returns state. | |
| 233 | + | let mut saturation: HashMap<i64, f64> = HashMap::new(); | |
| 234 | + | let selected_ids: std::collections::HashSet<i64> = | |
| 235 | + | p.slots.iter().map(|s| s.current().id).collect(); | |
| 236 | + | for slot in &p.slots { | |
| 237 | + | for tid in &slot.current().tag_ids { | |
| 238 | + | *saturation.entry(*tid).or_insert(0.0) += 1.0; | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | let mut scored: Vec<(f64, Exercise)> = self | |
| 242 | + | .exercises | |
| 243 | + | .iter() | |
| 244 | + | .filter(|e| !selected_ids.contains(&e.id)) | |
| 245 | + | .filter_map(|e| { | |
| 246 | + | let s = score(e, &self.readiness, days.get(&e.id).copied(), &saturation)?; | |
| 247 | + | Some((s, e.clone())) | |
| 248 | + | }) | |
| 249 | + | .collect(); | |
| 250 | + | if scored.is_empty() { | |
| 251 | + | self.status = "no more eligible exercises".to_string(); | |
| 252 | + | return; | |
| 253 | + | } | |
| 254 | + | scored.sort_by(|a, b| { | |
| 255 | + | b.0.partial_cmp(&a.0) | |
| 256 | + | .unwrap_or(std::cmp::Ordering::Equal) | |
| 257 | + | .then_with(|| a.1.name.cmp(&b.1.name)) | |
| 258 | + | }); | |
| 259 | + | let primary = scored.remove(0).1; | |
| 260 | + | let alternates: Vec<Exercise> = scored.into_iter().take(5).map(|(_, e)| e).collect(); | |
| 261 | + | p.slots.push(SlotState { | |
| 262 | + | primary, | |
| 263 | + | alternates, | |
| 264 | + | idx: 0, | |
| 265 | + | }); | |
| 266 | + | p.slot_cursor = p.slots.len() - 1; | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // -- rendering -------------------------------------------------------- | |
| 270 | + | ||
| 271 | + | pub fn render(&mut self, frame: &mut Frame, area: Rect, db: &Db) { | |
| 272 | + | let chunks = Layout::default() | |
| 273 | + | .direction(Direction::Vertical) | |
| 274 | + | .constraints([Constraint::Length(1), Constraint::Min(3), Constraint::Length(1)]) | |
| 275 | + | .split(area); | |
| 276 | + | ||
| 277 | + | let header = format!( | |
| 278 | + | "session: {} count: {} ([/] date +/- count)", | |
| 279 | + | self.date.format("%Y-%m-%d"), | |
| 280 | + | self.count | |
| 281 | + | ); | |
| 282 | + | frame.render_widget(Paragraph::new(header), chunks[0]); | |
| 283 | + | ||
| 284 | + | match &self.preview { | |
| 285 | + | Some(p) => self.render_preview(frame, chunks[1], p, db), | |
| 286 | + | None => self.render_setup(frame, chunks[1]), | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | let hint = match &self.preview { | |
| 290 | + | None => "j/k tag space cycle readiness c clear g generate", | |
| 291 | + | Some(_) => "j/k slot r/R cycle alt d drop a add e export esc back", | |
| 292 | + | }; | |
| 293 | + | frame.render_widget( | |
| 294 | + | Paragraph::new(Line::from(vec![ | |
| 295 | + | Span::raw(hint), | |
| 296 | + | Span::raw(" "), | |
| 297 | + | Span::styled( | |
| 298 | + | self.status.as_str(), | |
| 299 | + | Style::default().add_modifier(Modifier::DIM), | |
| 300 | + | ), | |
| 301 | + | ])), | |
| 302 | + | chunks[2], | |
| 303 | + | ); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | fn render_setup(&self, frame: &mut Frame, area: Rect) { | |
| 307 | + | let items: Vec<ListItem> = if self.tags.is_empty() { | |
| 308 | + | vec![ListItem::new( | |
| 309 | + | " no tags yet — add tags via the templates tab", | |
| 310 | + | )] | |
| 311 | + | } else { | |
| 312 | + | self.tags | |
| 313 | + | .iter() | |
| 314 | + | .enumerate() | |
| 315 | + | .map(|(i, t)| { | |
| 316 | + | let state = match self.readiness.get(&t.id) { | |
| 317 | + | None => "-", | |
| 318 | + | Some(Readiness::Ready) => "ready", | |
| 319 | + | Some(Readiness::Tired) => "tired", | |
| 320 | + | Some(Readiness::Sore) => "sore", | |
| 321 | + | }; | |
| 322 | + | let cursor = if i == self.setup_cursor { "> " } else { " " }; | |
| 323 | + | ListItem::new(format!("{cursor}{:<20} [{state}]", t.name)) | |
| 324 | + | }) | |
| 325 | + | .collect() | |
| 326 | + | }; | |
| 327 | + | let list = List::new(items) | |
| 328 | + | .block(Block::default().borders(Borders::ALL).title(" readiness ")); | |
| 329 | + | let mut ls = ListState::default(); | |
| 330 | + | ls.select(Some(self.setup_cursor)); | |
| 331 | + | frame.render_stateful_widget(list, area, &mut ls); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | fn render_preview(&self, frame: &mut Frame, area: Rect, p: &Preview, db: &Db) { | |
| 335 | + | let warmup_ids = warmup_tag_ids( | |
| 336 | + | &p.slots | |
| 337 | + | .iter() | |
| 338 | + | .map(|s| PickedSlot { | |
| 339 | + | primary: s.current().clone(), | |
| 340 | + | alternates: vec![], | |
| 341 | + | }) | |
| 342 | + | .collect::<Vec<_>>(), | |
| 343 | + | ); | |
| 344 | + | let warmup_names: Vec<String> = warmup_ids | |
| 345 | + | .iter() | |
| 346 | + | .filter_map(|id| self.tags.iter().find(|t| t.id == *id).map(|t| t.name.clone())) | |
| 347 | + | .collect(); | |
| 348 | + | let warmup_line = format!( | |
| 349 | + | "warmup: {}", | |
| 350 | + | if warmup_names.is_empty() { | |
| 351 | + | "-".to_string() | |
| 352 | + | } else { | |
| 353 | + | warmup_names.join(", ") | |
| 354 | + | } | |
| 355 | + | ); | |
| 356 | + | ||
| 357 | + | let mut lines: Vec<Line> = vec![ | |
| 358 | + | Line::from(Span::styled( | |
| 359 | + | warmup_line, | |
| 360 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 361 | + | )), | |
| 362 | + | Line::from(""), | |
| 363 | + | ]; | |
| 364 | + | ||
| 365 | + | for (i, slot) in p.slots.iter().enumerate() { | |
| 366 | + | let ex = slot.current(); | |
| 367 | + | let (prescription_text, glyph) = prescription_for(db, ex); | |
| 368 | + | let alt_marker = if slot.idx == 0 { | |
| 369 | + | String::new() | |
| 370 | + | } else { | |
| 371 | + | format!(" [alt {}/{}]", slot.idx, slot.alternates.len()) | |
| 372 | + | }; | |
| 373 | + | let cursor = if i == p.slot_cursor { "> " } else { " " }; | |
| 374 | + | let base = format!( | |
| 375 | + | "{cursor}{:>2}. {:<24} {} {}{}", | |
| 376 | + | i + 1, | |
| 377 | + | ex.name, | |
| 378 | + | prescription_text, | |
| 379 | + | glyph, | |
| 380 | + | alt_marker, | |
| 381 | + | ); | |
| 382 | + | if i == p.slot_cursor { | |
| 383 | + | lines.push(Line::from(Span::styled( | |
| 384 | + | base, | |
| 385 | + | Style::default().add_modifier(Modifier::REVERSED), | |
| 386 | + | ))); | |
| 387 | + | } else { | |
| 388 | + | lines.push(Line::from(base)); | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | let block = Block::default().borders(Borders::ALL).title(" preview "); | |
| 393 | + | let inner = block.inner(area); | |
| 394 | + | frame.render_widget(block, area); | |
| 395 | + | frame.render_widget(Paragraph::new(lines), inner); | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | fn cycle_slot(p: &mut Preview, delta: i32) { | |
| 400 | + | let Some(slot) = p.slots.get_mut(p.slot_cursor) else { | |
| 401 | + | return; | |
| 402 | + | }; | |
| 403 | + | let choices = 1 + slot.alternates.len() as i32; | |
| 404 | + | let next = (slot.idx as i32 + delta).rem_euclid(choices); | |
| 405 | + | slot.idx = next as usize; | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | fn prescription_for(db: &Db, ex: &Exercise) -> (String, &'static str) { | |
| 409 | + | match db.compute_prescription(ex.id) { | |
| 410 | + | Ok(PrescriptionResult::NoHistory) => ("seed on first log".to_string(), " "), | |
| 411 | + | Ok(PrescriptionResult::Prescribed(p)) => { | |
| 412 | + | let text = format_prescription(&p, &ex.load_unit); | |
| 413 | + | let glyph = direction_glyph(db, ex, p.load); | |
| 414 | + | (text, glyph) | |
| 415 | + | } | |
| 416 | + | Err(_) => ("(error)".to_string(), " "), | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | fn format_prescription(p: &Prescription, unit: &str) -> String { | |
| 421 | + | let state = match p.state { | |
| 422 | + | State::Progressing => "prog", | |
| 423 | + | State::Probation => "prob", | |
| 424 | + | State::Deloading => "deld", | |
| 425 | + | State::Rebuilding => "rebd", | |
| 426 | + | }; | |
| 427 | + | format!("{}x{} @ {} {} [{}]", p.sets, p.reps, p.load, unit, state) | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | fn direction_glyph(db: &Db, ex: &Exercise, next_load: f64) -> &'static str { | |
| 431 | + | let sessions = db.list_sessions_for_exercise(ex.id).unwrap_or_default(); | |
| 432 | + | let Some(last) = sessions.last() else { return " " }; | |
| 433 | + | let last_top = last.iter().map(|s| s.load).fold(f64::NEG_INFINITY, f64::max); | |
| 434 | + | if next_load > last_top { | |
| 435 | + | "up" | |
| 436 | + | } else if next_load < last_top { | |
| 437 | + | "down" | |
| 438 | + | } else { | |
| 439 | + | "flat" | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[cfg(test)] | |
| 444 | + | mod tests { | |
| 445 | + | use super::*; | |
| 446 | + | use crossterm::event::KeyEvent; | |
| 447 | + | use ripgrow_core::{ResistanceType, Unit}; | |
| 448 | + | ||
| 449 | + | fn key(c: KeyCode) -> KeyEvent { | |
| 450 | + | KeyEvent::new(c, KeyModifiers::empty()) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | fn setup() -> (Db, Vec<i64>, Vec<i64>) { | |
| 454 | + | let db = Db::open_in_memory().unwrap(); | |
| 455 | + | db.init_profile("self", Unit::Kg).unwrap(); | |
| 456 | + | let t1 = db.upsert_tag("chest").unwrap(); | |
| 457 | + | let t2 = db.upsert_tag("back").unwrap(); | |
| 458 | + | let t3 = db.upsert_tag("legs").unwrap(); | |
| 459 | + | let a = db | |
| 460 | + | .create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[t1.id]) | |
| 461 | + | .unwrap(); | |
| 462 | + | let b = db | |
| 463 | + | .create_exercise("row", ResistanceType::Freeweight, "kg", 2.5, &[t2.id]) | |
| 464 | + | .unwrap(); | |
| 465 | + | let c = db | |
| 466 | + | .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[t3.id]) | |
| 467 | + | .unwrap(); | |
| 468 | + | (db, vec![t1.id, t2.id, t3.id], vec![a, b, c]) | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | #[test] | |
| 472 | + | fn generate_produces_preview_and_writes_readiness() { | |
| 473 | + | let (db, _tags, _) = setup(); | |
| 474 | + | let mut screen = GenerateScreen::load(&db).unwrap(); | |
| 475 | + | // Tags come back alphabetically: back, chest, legs. Cursor starts | |
| 476 | + | // at 0 = back. Space cycles it to Ready. | |
| 477 | + | let cursor_tag_id = screen.tags[screen.setup_cursor].id; | |
| 478 | + | screen.on_key(&db, key(KeyCode::Char(' '))); | |
| 479 | + | assert_eq!( | |
| 480 | + | db.read_readiness(screen.date).unwrap().get(&cursor_tag_id), | |
| 481 | + | Some(&Readiness::Ready) | |
| 482 | + | ); | |
| 483 | + | screen.on_key(&db, key(KeyCode::Char('g'))); | |
| 484 | + | assert!(screen.preview.is_some()); | |
| 485 | + | assert!(screen.preview.as_ref().unwrap().slots.len() >= 1); | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | #[test] | |
| 489 | + | fn reroll_cycles_alternates() { | |
| 490 | + | let (db, tags, _) = setup(); | |
| 491 | + | let mut screen = GenerateScreen::load(&db).unwrap(); | |
| 492 | + | for t in &tags { | |
| 493 | + | db.set_readiness(screen.date, *t, Some(Readiness::Ready)) | |
| 494 | + | .unwrap(); | |
| 495 | + | } | |
| 496 | + | screen.readiness = db.read_readiness(screen.date).unwrap(); | |
| 497 | + | screen.on_key(&db, key(KeyCode::Char('g'))); | |
| 498 | + | let p = screen.preview.as_ref().unwrap(); | |
| 499 | + | assert!(!p.slots.is_empty()); | |
| 500 | + | let starting_idx = p.slots[0].idx; |
Lines truncated
| @@ -1,3 +1,4 @@ | |||
| 1 | + | pub mod generate; | |
| 1 | 2 | pub mod log; | |
| 2 | 3 | pub mod profile_picker; | |
| 3 | 4 | pub mod templates; |