Skip to main content

max / ripgrow

29.4 KB · 847 lines History Blame Raw
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 AnyPrescription, AnyPrescriptionResult, Db, EffortKind, Exercise, Kind, Prescription,
23 Readiness, RepsKind, State, Tag, TimedKind, TimedPrescription, compute_prescription_any,
24 };
25
26 use crate::pdf::{self, PrescriptionParts, SessionExport, SlotExport};
27
28 pub struct GenerateScreen {
29 date: NaiveDate,
30 exercises: Vec<Exercise>,
31 tags: Vec<Tag>,
32 readiness: HashMap<i64, Readiness>,
33 count: usize,
34 setup_cursor: usize,
35 preview: Option<Preview>,
36 pub status: String,
37 }
38
39 struct Preview {
40 slots: Vec<SlotState>,
41 slot_cursor: usize,
42 }
43
44 struct SlotState {
45 primary: Exercise,
46 alternates: Vec<Exercise>,
47 /// 0 = primary, 1..=alternates.len() indexes into `alternates`.
48 idx: usize,
49 }
50
51 impl SlotState {
52 fn current(&self) -> &Exercise {
53 if self.idx == 0 {
54 &self.primary
55 } else {
56 &self.alternates[self.idx - 1]
57 }
58 }
59 }
60
61 impl GenerateScreen {
62 pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
63 let date = Local::now().date_naive();
64 Ok(Self {
65 date,
66 exercises: db.list_exercises()?,
67 tags: db.list_tags()?,
68 readiness: db.read_readiness(date)?,
69 count: 4,
70 setup_cursor: 0,
71 preview: None,
72 status: String::new(),
73 })
74 }
75
76 fn reload_readiness(&mut self, db: &Db) {
77 if let Ok(r) = db.read_readiness(self.date) {
78 self.readiness = r;
79 }
80 }
81
82 pub fn on_key(&mut self, db: &Db, key: KeyEvent) {
83 if self.preview.is_some() {
84 self.on_preview_key(db, key);
85 } else {
86 self.on_setup_key(db, key);
87 }
88 }
89
90 // -- setup sub-mode ---------------------------------------------------
91
92 fn on_setup_key(&mut self, db: &Db, key: KeyEvent) {
93 match (key.code, key.modifiers) {
94 (KeyCode::Char('['), KeyModifiers::NONE) => {
95 self.date -= Duration::days(1);
96 self.reload_readiness(db);
97 }
98 (KeyCode::Char(']'), KeyModifiers::NONE) => {
99 self.date += Duration::days(1);
100 self.reload_readiness(db);
101 }
102 (KeyCode::Char('+') | KeyCode::Char('='), _) => {
103 self.count = (self.count + 1).min(20);
104 }
105 (KeyCode::Char('-') | KeyCode::Char('_'), _) => {
106 self.count = self.count.saturating_sub(1).max(1);
107 }
108 (KeyCode::Down | KeyCode::Char('j'), _) => self.move_cursor(1),
109 (KeyCode::Up | KeyCode::Char('k'), _) => self.move_cursor(-1),
110 (KeyCode::Char(' '), _) => self.cycle_readiness(db),
111 (KeyCode::Char('c'), KeyModifiers::NONE) => self.clear_readiness(db),
112 (KeyCode::Char('g') | KeyCode::Enter, _) => self.generate(db),
113 _ => {}
114 }
115 }
116
117 fn move_cursor(&mut self, delta: isize) {
118 if self.tags.is_empty() {
119 return;
120 }
121 let n = self.tags.len() as isize;
122 let next = (self.setup_cursor as isize + delta).rem_euclid(n);
123 self.setup_cursor = next as usize;
124 }
125
126 fn cycle_readiness(&mut self, db: &Db) {
127 let Some(tag) = self.tags.get(self.setup_cursor) else {
128 return;
129 };
130 let next = match self.readiness.get(&tag.id) {
131 None => Some(Readiness::Ready),
132 Some(Readiness::Ready) => Some(Readiness::Tired),
133 Some(Readiness::Tired) => Some(Readiness::Sore),
134 Some(Readiness::Sore) => None,
135 };
136 if let Err(e) = db.set_readiness(self.date, tag.id, next) {
137 self.status = format!("readiness save failed: {e}");
138 return;
139 }
140 match next {
141 Some(r) => {
142 self.readiness.insert(tag.id, r);
143 }
144 None => {
145 self.readiness.remove(&tag.id);
146 }
147 }
148 }
149
150 fn clear_readiness(&mut self, db: &Db) {
151 let Some(tag) = self.tags.get(self.setup_cursor) else {
152 return;
153 };
154 if let Err(e) = db.set_readiness(self.date, tag.id, None) {
155 self.status = format!("clear failed: {e}");
156 return;
157 }
158 self.readiness.remove(&tag.id);
159 }
160
161 fn generate(&mut self, db: &Db) {
162 if self.exercises.is_empty() {
163 self.status = "no templates yet — add some on the templates tab".to_string();
164 return;
165 }
166 let days = match db.days_since_last_map(self.date) {
167 Ok(m) => m,
168 Err(e) => {
169 self.status = format!("recency lookup failed: {e}");
170 return;
171 }
172 };
173 let picks = pick_session(&self.exercises, &self.readiness, &days, self.count, 5);
174 if picks.is_empty() {
175 self.status = "no eligible exercises (check sore tags)".to_string();
176 return;
177 }
178 self.preview = Some(Preview {
179 slots: picks
180 .into_iter()
181 .map(|p| SlotState {
182 primary: p.primary,
183 alternates: p.alternates,
184 idx: 0,
185 })
186 .collect(),
187 slot_cursor: 0,
188 });
189 self.status = "generated".to_string();
190 }
191
192 // -- preview sub-mode -------------------------------------------------
193
194 fn on_preview_key(&mut self, db: &Db, key: KeyEvent) {
195 let Some(p) = self.preview.as_mut() else { return };
196 match (key.code, key.modifiers) {
197 (KeyCode::Esc, _) => {
198 self.preview = None;
199 }
200 (KeyCode::Down | KeyCode::Char('j'), _) => {
201 if !p.slots.is_empty() {
202 p.slot_cursor = (p.slot_cursor + 1) % p.slots.len();
203 }
204 }
205 (KeyCode::Up | KeyCode::Char('k'), _) => {
206 if !p.slots.is_empty() {
207 p.slot_cursor =
208 (p.slot_cursor + p.slots.len() - 1) % p.slots.len();
209 }
210 }
211 (KeyCode::Char('r'), KeyModifiers::NONE) => cycle_slot(p, 1),
212 (KeyCode::Char('R'), _) | (KeyCode::Char('r'), KeyModifiers::SHIFT) => {
213 cycle_slot(p, -1);
214 }
215 (KeyCode::Char('d'), _) => {
216 if !p.slots.is_empty() {
217 p.slots.remove(p.slot_cursor);
218 if p.slot_cursor >= p.slots.len() && !p.slots.is_empty() {
219 p.slot_cursor = p.slots.len() - 1;
220 }
221 }
222 }
223 (KeyCode::Char('a'), _) => self.add_slot(db),
224 (KeyCode::Char('e'), _) => self.export_pdf(db),
225 (KeyCode::Char('p'), _) => self.push_supernote(db),
226 _ => {}
227 }
228 }
229
230 fn build_session(&self, db: &Db) -> Result<SessionExport, String> {
231 let p = self.preview.as_ref().ok_or_else(|| "no preview".to_string())?;
232 if p.slots.is_empty() {
233 return Err("no slots to export".to_string());
234 }
235
236 let warmup_ids = warmup_tag_ids(
237 &p.slots
238 .iter()
239 .map(|s| PickedSlot {
240 primary: s.current().clone(),
241 alternates: vec![],
242 })
243 .collect::<Vec<_>>(),
244 );
245 let warmup: Vec<String> = warmup_ids
246 .iter()
247 .filter_map(|id| self.tags.iter().find(|t| t.id == *id).map(|t| t.name.clone()))
248 .collect();
249
250 let mut slots: Vec<SlotExport> = Vec::new();
251 for slot in &p.slots {
252 let ex = slot.current();
253 let (parts, glyph) = match compute_prescription_any(db, ex) {
254 Ok(AnyPrescriptionResult::Prescribed(pres)) => {
255 let parts = PrescriptionParts::from_any(ex, &pres);
256 let g = direction_glyph(db, ex, &pres);
257 (parts, g.to_string())
258 }
259 Ok(AnyPrescriptionResult::NoHistory) => {
260 (PrescriptionParts::no_history(), String::new())
261 }
262 Err(e) => return Err(format!("prescription failed: {e}")),
263 };
264 slots.push(SlotExport {
265 name: ex.name.clone(),
266 sets: parts.sets,
267 reps: parts.reps,
268 load: parts.load,
269 unit: parts.unit,
270 glyph,
271 });
272 }
273
274 Ok(SessionExport {
275 profile: db
276 .profile_name()
277 .ok()
278 .flatten()
279 .unwrap_or_else(|| "profile".to_string()),
280 date: self.date,
281 warmup,
282 slots,
283 })
284 }
285
286 fn export_pdf(&mut self, db: &Db) {
287 let session = match self.build_session(db) {
288 Ok(s) => s,
289 Err(msg) => {
290 self.status = msg;
291 return;
292 }
293 };
294
295 let output_dir = dirs::download_dir()
296 .or_else(dirs::home_dir)
297 .unwrap_or_else(std::env::temp_dir);
298
299 match pdf::export_to_pdf(&session, &output_dir) {
300 Ok(path) => self.status = format!("exported: {}", path.display()),
301 Err(e) => self.status = format!("export failed: {e}"),
302 }
303 }
304
305 fn push_supernote(&mut self, db: &Db) {
306 let Some(cfg) = crate::config::load().supernote else {
307 let hint = crate::config::path()
308 .map(|p| p.display().to_string())
309 .unwrap_or_else(|| "config.toml".to_string());
310 self.status = format!("no [supernote] host in {hint}");
311 return;
312 };
313
314 let session = match self.build_session(db) {
315 Ok(s) => s,
316 Err(msg) => {
317 self.status = msg;
318 return;
319 }
320 };
321
322 let tmp = std::env::temp_dir().join("ripgrow-push");
323 if let Err(e) = std::fs::create_dir_all(&tmp) {
324 self.status = format!("temp dir failed: {e}");
325 return;
326 }
327
328 let pdf_path = match pdf::export_to_pdf(&session, &tmp) {
329 Ok(p) => p,
330 Err(e) => {
331 self.status = format!("export failed: {e}");
332 return;
333 }
334 };
335 let bytes = match std::fs::read(&pdf_path) {
336 Ok(b) => b,
337 Err(e) => {
338 self.status = format!("read failed: {e}");
339 return;
340 }
341 };
342 let filename = pdf_path
343 .file_name()
344 .and_then(|s| s.to_str())
345 .unwrap_or("workout.pdf");
346
347 let mut sn = supernote_push::Supernote::at(&cfg.host);
348 if let Some(pc) = &cfg.passcode {
349 sn = sn.with_passcode(pc);
350 }
351
352 match sn.push_pdf(&cfg.remote_dir, filename, &bytes) {
353 Ok(()) => self.status = format!("pushed: {filename} -> {}", cfg.host),
354 Err(e) => {
355 self.status = format!("push failed ({e}); pdf kept at {}", pdf_path.display());
356 }
357 }
358 }
359
360 fn add_slot(&mut self, db: &Db) {
361 let Some(p) = self.preview.as_mut() else { return };
362 let days = db.days_since_last_map(self.date).unwrap_or_default();
363 // Rebuild saturation from current selections so the added slot
364 // respects the diminishing-returns state.
365 let mut saturation: HashMap<i64, f64> = HashMap::new();
366 let selected_ids: std::collections::HashSet<i64> =
367 p.slots.iter().map(|s| s.current().id).collect();
368 for slot in &p.slots {
369 for tid in &slot.current().tag_ids {
370 *saturation.entry(*tid).or_insert(0.0) += 1.0;
371 }
372 }
373 let mut scored: Vec<(f64, Exercise)> = self
374 .exercises
375 .iter()
376 .filter(|e| !selected_ids.contains(&e.id))
377 .filter_map(|e| {
378 let s = score(e, &self.readiness, days.get(&e.id).copied(), &saturation)?;
379 Some((s, e.clone()))
380 })
381 .collect();
382 if scored.is_empty() {
383 self.status = "no more eligible exercises".to_string();
384 return;
385 }
386 scored.sort_by(|a, b| {
387 b.0.partial_cmp(&a.0)
388 .unwrap_or(std::cmp::Ordering::Equal)
389 .then_with(|| a.1.name.cmp(&b.1.name))
390 });
391 let primary = scored.remove(0).1;
392 let alternates: Vec<Exercise> = scored.into_iter().take(5).map(|(_, e)| e).collect();
393 p.slots.push(SlotState {
394 primary,
395 alternates,
396 idx: 0,
397 });
398 p.slot_cursor = p.slots.len() - 1;
399 }
400
401 // -- rendering --------------------------------------------------------
402
403 pub fn render(&mut self, frame: &mut Frame, area: Rect, db: &Db) {
404 let chunks = Layout::default()
405 .direction(Direction::Vertical)
406 .constraints([Constraint::Length(1), Constraint::Min(3), Constraint::Length(1)])
407 .split(area);
408
409 let header = format!(
410 "session: {} count: {} ([/] date +/- count)",
411 self.date.format("%Y-%m-%d"),
412 self.count
413 );
414 frame.render_widget(Paragraph::new(header), chunks[0]);
415
416 match &self.preview {
417 Some(p) => self.render_preview(frame, chunks[1], p, db),
418 None => self.render_setup(frame, chunks[1]),
419 }
420
421 let hint = match &self.preview {
422 None => "j/k tag space cycle readiness c clear g generate",
423 Some(_) => "j/k slot r/R cycle alt d drop a add e export p push esc back",
424 };
425 frame.render_widget(
426 Paragraph::new(Line::from(vec![
427 Span::raw(hint),
428 Span::raw(" "),
429 Span::styled(
430 self.status.as_str(),
431 Style::default().add_modifier(Modifier::DIM),
432 ),
433 ])),
434 chunks[2],
435 );
436 }
437
438 fn render_setup(&self, frame: &mut Frame, area: Rect) {
439 let items: Vec<ListItem> = if self.tags.is_empty() {
440 vec![ListItem::new(
441 " no tags yet — add tags via the templates tab",
442 )]
443 } else {
444 self.tags
445 .iter()
446 .enumerate()
447 .map(|(i, t)| {
448 let state = match self.readiness.get(&t.id) {
449 None => "-",
450 Some(Readiness::Ready) => "ready",
451 Some(Readiness::Tired) => "tired",
452 Some(Readiness::Sore) => "sore",
453 };
454 let cursor = if i == self.setup_cursor { "> " } else { " " };
455 ListItem::new(format!("{cursor}{:<20} [{state}]", t.name))
456 })
457 .collect()
458 };
459 let list = List::new(items)
460 .block(Block::default().borders(Borders::ALL).title(" readiness "));
461 let mut ls = ListState::default();
462 ls.select(Some(self.setup_cursor));
463 frame.render_stateful_widget(list, area, &mut ls);
464 }
465
466 fn render_preview(&self, frame: &mut Frame, area: Rect, p: &Preview, db: &Db) {
467 let warmup_ids = warmup_tag_ids(
468 &p.slots
469 .iter()
470 .map(|s| PickedSlot {
471 primary: s.current().clone(),
472 alternates: vec![],
473 })
474 .collect::<Vec<_>>(),
475 );
476 let warmup_names: Vec<String> = warmup_ids
477 .iter()
478 .filter_map(|id| self.tags.iter().find(|t| t.id == *id).map(|t| t.name.clone()))
479 .collect();
480 let warmup_line = format!(
481 "warmup: {}",
482 if warmup_names.is_empty() {
483 "-".to_string()
484 } else {
485 warmup_names.join(", ")
486 }
487 );
488
489 let mut lines: Vec<Line> = vec![
490 Line::from(Span::styled(
491 warmup_line,
492 Style::default().add_modifier(Modifier::BOLD),
493 )),
494 Line::from(""),
495 ];
496
497 for (i, slot) in p.slots.iter().enumerate() {
498 let ex = slot.current();
499 let (prescription_text, glyph) = prescription_for(db, ex);
500 let alt_marker = if slot.idx == 0 {
501 String::new()
502 } else {
503 format!(" [alt {}/{}]", slot.idx, slot.alternates.len())
504 };
505 let cursor = if i == p.slot_cursor { "> " } else { " " };
506 let base = format!(
507 "{cursor}{:>2}. {:<24} {} {}{}",
508 i + 1,
509 ex.name,
510 prescription_text,
511 glyph,
512 alt_marker,
513 );
514 if i == p.slot_cursor {
515 lines.push(Line::from(Span::styled(
516 base,
517 Style::default().add_modifier(Modifier::REVERSED),
518 )));
519 } else {
520 lines.push(Line::from(base));
521 }
522 }
523
524 let block = Block::default().borders(Borders::ALL).title(" preview ");
525 let inner = block.inner(area);
526 frame.render_widget(block, area);
527 frame.render_widget(Paragraph::new(lines), inner);
528 }
529 }
530
531 fn cycle_slot(p: &mut Preview, delta: i32) {
532 let Some(slot) = p.slots.get_mut(p.slot_cursor) else {
533 return;
534 };
535 let choices = 1 + slot.alternates.len() as i32;
536 let next = (slot.idx as i32 + delta).rem_euclid(choices);
537 slot.idx = next as usize;
538 }
539
540 fn prescription_for(db: &Db, ex: &Exercise) -> (String, &'static str) {
541 match compute_prescription_any(db, ex) {
542 Ok(AnyPrescriptionResult::NoHistory) => ("seed on first log".to_string(), " "),
543 Ok(AnyPrescriptionResult::Prescribed(p)) => {
544 let text = format_prescription_any(&p, ex.load_unit.as_str());
545 let glyph = direction_glyph(db, ex, &p);
546 (text, glyph)
547 }
548 Err(_) => ("(error)".to_string(), " "),
549 }
550 }
551
552 fn format_prescription_any(p: &AnyPrescription, unit: &str) -> String {
553 match p {
554 AnyPrescription::Reps(p) => format_reps_prescription(p, unit),
555 AnyPrescription::Timed(p) => format_timed_prescription(p),
556 // Distance is track-only; compute_prescription_any never returns
557 // Prescribed for it today. Kept for exhaustiveness.
558 AnyPrescription::Distance(_) => "(distance)".to_string(),
559 }
560 }
561
562 fn format_reps_prescription(p: &Prescription, unit: &str) -> String {
563 format!(
564 "{}x{} @ {} {} [{}]",
565 p.sets,
566 p.reps,
567 p.load,
568 unit,
569 state_tag(p.state),
570 )
571 }
572
573 fn format_timed_prescription(p: &TimedPrescription) -> String {
574 format!("{}x {} [{}]", p.sets, p.duration, state_tag(p.state))
575 }
576
577 fn state_tag(s: State) -> &'static str {
578 match s {
579 State::Progressing => "prog",
580 State::Probation => "prob",
581 State::Deloading => "deld",
582 State::Rebuilding => "rebd",
583 }
584 }
585
586 /// Direction arrow versus the most recent session for this exercise.
587 /// Dispatches on effort kind: reps compares top load, timed compares
588 /// top duration, distance is neutral (track-only, no notion of "up").
589 fn direction_glyph(db: &Db, ex: &Exercise, next: &AnyPrescription) -> &'static str {
590 match (ex.effort_kind, next) {
591 (EffortKind::Reps, AnyPrescription::Reps(p)) => {
592 let sessions =
593 RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
594 let Some(last) = sessions.last() else { return " " };
595 let last_top = last
596 .iter()
597 .map(|s| s.load.get())
598 .fold(f64::NEG_INFINITY, f64::max);
599 compare(p.load.get(), last_top)
600 }
601 (EffortKind::Timed, AnyPrescription::Timed(p)) => {
602 let sessions =
603 TimedKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
604 let Some(last) = sessions.last() else { return " " };
605 let last_top = last.iter().map(|s| s.duration.seconds()).max().unwrap_or(0);
606 compare(p.duration.seconds() as f64, last_top as f64)
607 }
608 (EffortKind::Distance, _) => " ",
609 _ => " ",
610 }
611 }
612
613 fn compare(next: f64, last: f64) -> &'static str {
614 if next > last {
615 "up"
616 } else if next < last {
617 "down"
618 } else {
619 "flat"
620 }
621 }
622
623 #[cfg(test)]
624 mod tests {
625 use super::*;
626 use crossterm::event::KeyEvent;
627 use ripgrow_core::{ResistanceType, LoadUnit};
628
629 fn key(c: KeyCode) -> KeyEvent {
630 KeyEvent::new(c, KeyModifiers::empty())
631 }
632
633 fn setup() -> (Db, Vec<i64>, Vec<i64>) {
634 let db = Db::open_in_memory().unwrap();
635 db.init_profile("self", LoadUnit::Kg).unwrap();
636 let t1 = db.upsert_tag("chest").unwrap();
637 let t2 = db.upsert_tag("back").unwrap();
638 let t3 = db.upsert_tag("legs").unwrap();
639 let a = db
640 .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t1.id])
641 .unwrap();
642 let b = db
643 .create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t2.id])
644 .unwrap();
645 let c = db
646 .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t3.id])
647 .unwrap();
648 (db, vec![t1.id, t2.id, t3.id], vec![a, b, c])
649 }
650
651 #[test]
652 fn generate_produces_preview_and_writes_readiness() {
653 let (db, _tags, _) = setup();
654 let mut screen = GenerateScreen::load(&db).unwrap();
655 // Tags come back alphabetically: back, chest, legs. Cursor starts
656 // at 0 = back. Space cycles it to Ready.
657 let cursor_tag_id = screen.tags[screen.setup_cursor].id;
658 screen.on_key(&db, key(KeyCode::Char(' ')));
659 assert_eq!(
660 db.read_readiness(screen.date).unwrap().get(&cursor_tag_id),
661 Some(&Readiness::Ready)
662 );
663 screen.on_key(&db, key(KeyCode::Char('g')));
664 assert!(screen.preview.is_some());
665 assert!(screen.preview.as_ref().unwrap().slots.len() >= 1);
666 }
667
668 #[test]
669 fn reroll_cycles_alternates() {
670 let (db, tags, _) = setup();
671 let mut screen = GenerateScreen::load(&db).unwrap();
672 for t in &tags {
673 db.set_readiness(screen.date, *t, Some(Readiness::Ready))
674 .unwrap();
675 }
676 screen.readiness = db.read_readiness(screen.date).unwrap();
677 screen.on_key(&db, key(KeyCode::Char('g')));
678 let p = screen.preview.as_ref().unwrap();
679 assert!(!p.slots.is_empty());
680 let starting_idx = p.slots[0].idx;
681 // Only cycle if alternates exist for the first slot.
682 if !p.slots[0].alternates.is_empty() {
683 screen.on_key(&db, key(KeyCode::Char('r')));
684 let p = screen.preview.as_ref().unwrap();
685 assert_ne!(p.slots[0].idx, starting_idx);
686 }
687 }
688
689 #[test]
690 fn d_drops_slot() {
691 let (db, tags, _) = setup();
692 let mut screen = GenerateScreen::load(&db).unwrap();
693 for t in &tags {
694 db.set_readiness(screen.date, *t, Some(Readiness::Ready))
695 .unwrap();
696 }
697 screen.readiness = db.read_readiness(screen.date).unwrap();
698 screen.on_key(&db, key(KeyCode::Char('g')));
699 let n = screen.preview.as_ref().unwrap().slots.len();
700 screen.on_key(&db, key(KeyCode::Char('d')));
701 assert_eq!(screen.preview.as_ref().unwrap().slots.len(), n - 1);
702 }
703
704 #[test]
705 fn a_adds_slot_when_pool_has_more() {
706 let (db, tags, _) = setup();
707 let mut screen = GenerateScreen::load(&db).unwrap();
708 for t in &tags {
709 db.set_readiness(screen.date, *t, Some(Readiness::Ready))
710 .unwrap();
711 }
712 screen.readiness = db.read_readiness(screen.date).unwrap();
713 screen.count = 1;
714 screen.on_key(&db, key(KeyCode::Char('g')));
715 assert_eq!(screen.preview.as_ref().unwrap().slots.len(), 1);
716 screen.on_key(&db, key(KeyCode::Char('a')));
717 assert_eq!(screen.preview.as_ref().unwrap().slots.len(), 2);
718 }
719
720 #[test]
721 fn sore_tag_blocks_exercise() {
722 let (db, tags, _) = setup();
723 let mut screen = GenerateScreen::load(&db).unwrap();
724 // Sore back → row is excluded.
725 db.set_readiness(screen.date, tags[1], Some(Readiness::Sore))
726 .unwrap();
727 // Ready chest + legs so we still have candidates.
728 db.set_readiness(screen.date, tags[0], Some(Readiness::Ready))
729 .unwrap();
730 db.set_readiness(screen.date, tags[2], Some(Readiness::Ready))
731 .unwrap();
732 screen.readiness = db.read_readiness(screen.date).unwrap();
733 screen.on_key(&db, key(KeyCode::Char('g')));
734 let names: Vec<String> = screen
735 .preview
736 .as_ref()
737 .unwrap()
738 .slots
739 .iter()
740 .map(|s| s.primary.name.clone())
741 .collect();
742 assert!(!names.contains(&"row".to_string()));
743 }
744
745 #[test]
746 fn esc_returns_from_preview_to_setup() {
747 let (db, tags, _) = setup();
748 let mut screen = GenerateScreen::load(&db).unwrap();
749 for t in &tags {
750 db.set_readiness(screen.date, *t, Some(Readiness::Ready))
751 .unwrap();
752 }
753 screen.readiness = db.read_readiness(screen.date).unwrap();
754 screen.on_key(&db, key(KeyCode::Char('g')));
755 assert!(screen.preview.is_some());
756 screen.on_key(&db, key(KeyCode::Esc));
757 assert!(screen.preview.is_none());
758 }
759
760 #[test]
761 fn prescription_for_reps_renders_load_and_state() {
762 use ripgrow_core::{Load, RepsPayload, Reps as CoreReps, Rpe};
763 let db = Db::open_in_memory().unwrap();
764 db.init_profile("self", LoadUnit::Kg).unwrap();
765 let ex_id = db
766 .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
767 .unwrap();
768 let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
769 RepsKind::append(
770 &db,
771 ex_id,
772 d,
773 RepsPayload::new(Load::new(80.0).unwrap(), CoreReps::new(5).unwrap()),
774 Rpe::new(3).unwrap(),
775 false,
776 )
777 .unwrap();
778 let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap();
779 let (text, glyph) = prescription_for(&db, &ex);
780 // Clean-hit first session -> +increment -> 82.5.
781 assert!(text.contains("82.5"), "text was: {text}");
782 assert!(text.contains("prog"), "text was: {text}");
783 assert_eq!(glyph, "up");
784 }
785
786 #[test]
787 fn prescription_for_timed_renders_duration_and_state() {
788 use ripgrow_core::{Duration as CoreDuration, Rpe, TimedPayload};
789 let db = Db::open_in_memory().unwrap();
790 db.init_profile("self", LoadUnit::Kg).unwrap();
791 let ex_id = db
792 .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
793 .unwrap();
794 let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
795 TimedKind::append(
796 &db,
797 ex_id,
798 d,
799 TimedPayload::new(CoreDuration::from_seconds(60).unwrap()),
800 Rpe::new(3).unwrap(),
801 false,
802 )
803 .unwrap();
804 let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap();
805 let (text, glyph) = prescription_for(&db, &ex);
806 // Clean-hit first session -> +increment (10 sec) -> 70 sec -> "1m10s".
807 assert!(text.contains("1m10s"), "text was: {text}");
808 assert!(text.contains("prog"), "text was: {text}");
809 assert_eq!(glyph, "up");
810 }
811
812 #[test]
813 fn prescription_for_distance_is_seed_regardless_of_history() {
814 use ripgrow_core::{
815 Distance as CoreDistance, DistanceKind, DistancePayload, Duration as CoreDuration, Rpe,
816 };
817 let db = Db::open_in_memory().unwrap();
818 db.init_profile("self", LoadUnit::Kg).unwrap();
819 let ex_id = db
820 .create_exercise(
821 "5k run",
822 ResistanceType::CardioDistance,
823 LoadUnit::Kg,
824 0.0,
825 &[],
826 )
827 .unwrap();
828 let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
829 DistanceKind::append(
830 &db,
831 ex_id,
832 d,
833 DistancePayload::new(
834 CoreDistance::from_meters(5000.0).unwrap(),
835 CoreDuration::from_seconds(1500).unwrap(),
836 ),
837 Rpe::new(3).unwrap(),
838 false,
839 )
840 .unwrap();
841 let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap();
842 let (text, _glyph) = prescription_for(&db, &ex);
843 // Distance is track-only — always NoHistory.
844 assert_eq!(text, "seed on first log");
845 }
846 }
847