Skip to main content

max / ripgrow

generate screen: scorer, picker, reroll UX ripgrow-core::generation holds the scorer and greedy picker. Rules match the design note: Ready tag +1.0, Tired -1.0, Sore is a hard block, recency bonus is days/7 capped at 3.0 (never-done maxes it), and per-tag saturation diminishes future picks with 1/(1+sat). The picker breaks ties by name so preview output is stable across regenerations. Db helpers: read_readiness / set_readiness (upsert with ON CONFLICT), days_since_last_map computes days-since per exercise-id from MAX(session_date). Generate tab (Alt+3) has two sub-modes. Setup: session date, per-tag readiness cycled with Space (none -> ready -> tired -> sore -> none), count with +/-. `g` or Enter runs the picker and switches to Preview. Preview shows the warmup line (union of tag names ordered by frequency), then numbered slots with prescription (state + set×rep@load) and directional glyph vs the last session's top load. Reroll UX per the design: r/R cycle alternates, d drops the current slot, a appends a new slot that respects current saturation, e is a status stub until the PDF slice lands. Esc returns to setup. Creating a template on the templates tab refreshes the Generate screen so new exercises and tags appear immediately. 71 tests total (48 core + 23 tui), all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:52 UTC
Commit: be128a06fb4f839beb0b0c00e4c89cb5d868c349
Parent: ef7edf6
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;