//! Generate screen. Two sub-modes: //! //! - **Setup.** Pick session date, mark readiness per tag (unset/ready/ //! tired/sore), pick a target exercise count. `g` or Enter runs the //! picker. //! - **Preview.** Numbered slot list with the chosen exercise, the state- //! machine prescription, and a directional glyph versus the last session. //! Reroll keys (`r`, `R`, `d`, `a`) map to the design's UX; `e` is a PDF //! export stub until slice 6. use std::collections::HashMap; use chrono::{Duration, Local, NaiveDate}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ripgrow_core::generation::{PickedSlot, pick_session, score, warmup_tag_ids}; use ripgrow_core::{ AnyPrescription, AnyPrescriptionResult, Db, EffortKind, Exercise, Kind, Prescription, Readiness, RepsKind, State, Tag, TimedKind, TimedPrescription, compute_prescription_any, }; use crate::pdf::{self, PrescriptionParts, SessionExport, SlotExport}; pub struct GenerateScreen { date: NaiveDate, exercises: Vec, tags: Vec, readiness: HashMap, count: usize, setup_cursor: usize, preview: Option, pub status: String, } struct Preview { slots: Vec, slot_cursor: usize, } struct SlotState { primary: Exercise, alternates: Vec, /// 0 = primary, 1..=alternates.len() indexes into `alternates`. idx: usize, } impl SlotState { fn current(&self) -> &Exercise { if self.idx == 0 { &self.primary } else { &self.alternates[self.idx - 1] } } } impl GenerateScreen { pub fn load(db: &Db) -> Result { let date = Local::now().date_naive(); Ok(Self { date, exercises: db.list_exercises()?, tags: db.list_tags()?, readiness: db.read_readiness(date)?, count: 4, setup_cursor: 0, preview: None, status: String::new(), }) } fn reload_readiness(&mut self, db: &Db) { if let Ok(r) = db.read_readiness(self.date) { self.readiness = r; } } pub fn on_key(&mut self, db: &Db, key: KeyEvent) { if self.preview.is_some() { self.on_preview_key(db, key); } else { self.on_setup_key(db, key); } } // -- setup sub-mode --------------------------------------------------- fn on_setup_key(&mut self, db: &Db, key: KeyEvent) { match (key.code, key.modifiers) { (KeyCode::Char('['), KeyModifiers::NONE) => { self.date -= Duration::days(1); self.reload_readiness(db); } (KeyCode::Char(']'), KeyModifiers::NONE) => { self.date += Duration::days(1); self.reload_readiness(db); } (KeyCode::Char('+') | KeyCode::Char('='), _) => { self.count = (self.count + 1).min(20); } (KeyCode::Char('-') | KeyCode::Char('_'), _) => { self.count = self.count.saturating_sub(1).max(1); } (KeyCode::Down | KeyCode::Char('j'), _) => self.move_cursor(1), (KeyCode::Up | KeyCode::Char('k'), _) => self.move_cursor(-1), (KeyCode::Char(' '), _) => self.cycle_readiness(db), (KeyCode::Char('c'), KeyModifiers::NONE) => self.clear_readiness(db), (KeyCode::Char('g') | KeyCode::Enter, _) => self.generate(db), _ => {} } } fn move_cursor(&mut self, delta: isize) { if self.tags.is_empty() { return; } let n = self.tags.len() as isize; let next = (self.setup_cursor as isize + delta).rem_euclid(n); self.setup_cursor = next as usize; } fn cycle_readiness(&mut self, db: &Db) { let Some(tag) = self.tags.get(self.setup_cursor) else { return; }; let next = match self.readiness.get(&tag.id) { None => Some(Readiness::Ready), Some(Readiness::Ready) => Some(Readiness::Tired), Some(Readiness::Tired) => Some(Readiness::Sore), Some(Readiness::Sore) => None, }; if let Err(e) = db.set_readiness(self.date, tag.id, next) { self.status = format!("readiness save failed: {e}"); return; } match next { Some(r) => { self.readiness.insert(tag.id, r); } None => { self.readiness.remove(&tag.id); } } } fn clear_readiness(&mut self, db: &Db) { let Some(tag) = self.tags.get(self.setup_cursor) else { return; }; if let Err(e) = db.set_readiness(self.date, tag.id, None) { self.status = format!("clear failed: {e}"); return; } self.readiness.remove(&tag.id); } fn generate(&mut self, db: &Db) { if self.exercises.is_empty() { self.status = "no templates yet — add some on the templates tab".to_string(); return; } let days = match db.days_since_last_map(self.date) { Ok(m) => m, Err(e) => { self.status = format!("recency lookup failed: {e}"); return; } }; let picks = pick_session(&self.exercises, &self.readiness, &days, self.count, 5); if picks.is_empty() { self.status = "no eligible exercises (check sore tags)".to_string(); return; } self.preview = Some(Preview { slots: picks .into_iter() .map(|p| SlotState { primary: p.primary, alternates: p.alternates, idx: 0, }) .collect(), slot_cursor: 0, }); self.status = "generated".to_string(); } // -- preview sub-mode ------------------------------------------------- fn on_preview_key(&mut self, db: &Db, key: KeyEvent) { let Some(p) = self.preview.as_mut() else { return }; match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.preview = None; } (KeyCode::Down | KeyCode::Char('j'), _) => { if !p.slots.is_empty() { p.slot_cursor = (p.slot_cursor + 1) % p.slots.len(); } } (KeyCode::Up | KeyCode::Char('k'), _) => { if !p.slots.is_empty() { p.slot_cursor = (p.slot_cursor + p.slots.len() - 1) % p.slots.len(); } } (KeyCode::Char('r'), KeyModifiers::NONE) => cycle_slot(p, 1), (KeyCode::Char('R'), _) | (KeyCode::Char('r'), KeyModifiers::SHIFT) => { cycle_slot(p, -1); } (KeyCode::Char('d'), _) => { if !p.slots.is_empty() { p.slots.remove(p.slot_cursor); if p.slot_cursor >= p.slots.len() && !p.slots.is_empty() { p.slot_cursor = p.slots.len() - 1; } } } (KeyCode::Char('a'), _) => self.add_slot(db), (KeyCode::Char('e'), _) => self.export_pdf(db), (KeyCode::Char('p'), _) => self.push_supernote(db), _ => {} } } fn build_session(&self, db: &Db) -> Result { let p = self.preview.as_ref().ok_or_else(|| "no preview".to_string())?; if p.slots.is_empty() { return Err("no slots to export".to_string()); } let warmup_ids = warmup_tag_ids( &p.slots .iter() .map(|s| PickedSlot { primary: s.current().clone(), alternates: vec![], }) .collect::>(), ); let warmup: Vec = warmup_ids .iter() .filter_map(|id| self.tags.iter().find(|t| t.id == *id).map(|t| t.name.clone())) .collect(); let mut slots: Vec = Vec::new(); for slot in &p.slots { let ex = slot.current(); let (parts, glyph) = match compute_prescription_any(db, ex) { Ok(AnyPrescriptionResult::Prescribed(pres)) => { let parts = PrescriptionParts::from_any(ex, &pres); let g = direction_glyph(db, ex, &pres); (parts, g.to_string()) } Ok(AnyPrescriptionResult::NoHistory) => { (PrescriptionParts::no_history(), String::new()) } Err(e) => return Err(format!("prescription failed: {e}")), }; slots.push(SlotExport { name: ex.name.clone(), sets: parts.sets, reps: parts.reps, load: parts.load, unit: parts.unit, glyph, }); } Ok(SessionExport { profile: db .profile_name() .ok() .flatten() .unwrap_or_else(|| "profile".to_string()), date: self.date, warmup, slots, }) } fn export_pdf(&mut self, db: &Db) { let session = match self.build_session(db) { Ok(s) => s, Err(msg) => { self.status = msg; return; } }; let output_dir = dirs::download_dir() .or_else(dirs::home_dir) .unwrap_or_else(std::env::temp_dir); match pdf::export_to_pdf(&session, &output_dir) { Ok(path) => self.status = format!("exported: {}", path.display()), Err(e) => self.status = format!("export failed: {e}"), } } fn push_supernote(&mut self, db: &Db) { let Some(cfg) = crate::config::load().supernote else { let hint = crate::config::path() .map(|p| p.display().to_string()) .unwrap_or_else(|| "config.toml".to_string()); self.status = format!("no [supernote] host in {hint}"); return; }; let session = match self.build_session(db) { Ok(s) => s, Err(msg) => { self.status = msg; return; } }; let tmp = std::env::temp_dir().join("ripgrow-push"); if let Err(e) = std::fs::create_dir_all(&tmp) { self.status = format!("temp dir failed: {e}"); return; } let pdf_path = match pdf::export_to_pdf(&session, &tmp) { Ok(p) => p, Err(e) => { self.status = format!("export failed: {e}"); return; } }; let bytes = match std::fs::read(&pdf_path) { Ok(b) => b, Err(e) => { self.status = format!("read failed: {e}"); return; } }; let filename = pdf_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("workout.pdf"); let mut sn = supernote_push::Supernote::at(&cfg.host); if let Some(pc) = &cfg.passcode { sn = sn.with_passcode(pc); } match sn.push_pdf(&cfg.remote_dir, filename, &bytes) { Ok(()) => self.status = format!("pushed: {filename} -> {}", cfg.host), Err(e) => { self.status = format!("push failed ({e}); pdf kept at {}", pdf_path.display()); } } } fn add_slot(&mut self, db: &Db) { let Some(p) = self.preview.as_mut() else { return }; let days = db.days_since_last_map(self.date).unwrap_or_default(); // Rebuild saturation from current selections so the added slot // respects the diminishing-returns state. let mut saturation: HashMap = HashMap::new(); let selected_ids: std::collections::HashSet = p.slots.iter().map(|s| s.current().id).collect(); for slot in &p.slots { for tid in &slot.current().tag_ids { *saturation.entry(*tid).or_insert(0.0) += 1.0; } } let mut scored: Vec<(f64, Exercise)> = self .exercises .iter() .filter(|e| !selected_ids.contains(&e.id)) .filter_map(|e| { let s = score(e, &self.readiness, days.get(&e.id).copied(), &saturation)?; Some((s, e.clone())) }) .collect(); if scored.is_empty() { self.status = "no more eligible exercises".to_string(); return; } scored.sort_by(|a, b| { b.0.partial_cmp(&a.0) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.1.name.cmp(&b.1.name)) }); let primary = scored.remove(0).1; let alternates: Vec = scored.into_iter().take(5).map(|(_, e)| e).collect(); p.slots.push(SlotState { primary, alternates, idx: 0, }); p.slot_cursor = p.slots.len() - 1; } // -- rendering -------------------------------------------------------- pub fn render(&mut self, frame: &mut Frame, area: Rect, db: &Db) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(3), Constraint::Length(1)]) .split(area); let header = format!( "session: {} count: {} ([/] date +/- count)", self.date.format("%Y-%m-%d"), self.count ); frame.render_widget(Paragraph::new(header), chunks[0]); match &self.preview { Some(p) => self.render_preview(frame, chunks[1], p, db), None => self.render_setup(frame, chunks[1]), } let hint = match &self.preview { None => "j/k tag space cycle readiness c clear g generate", Some(_) => "j/k slot r/R cycle alt d drop a add e export p push esc back", }; frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw(hint), Span::raw(" "), Span::styled( self.status.as_str(), Style::default().add_modifier(Modifier::DIM), ), ])), chunks[2], ); } fn render_setup(&self, frame: &mut Frame, area: Rect) { let items: Vec = if self.tags.is_empty() { vec![ListItem::new( " no tags yet — add tags via the templates tab", )] } else { self.tags .iter() .enumerate() .map(|(i, t)| { let state = match self.readiness.get(&t.id) { None => "-", Some(Readiness::Ready) => "ready", Some(Readiness::Tired) => "tired", Some(Readiness::Sore) => "sore", }; let cursor = if i == self.setup_cursor { "> " } else { " " }; ListItem::new(format!("{cursor}{:<20} [{state}]", t.name)) }) .collect() }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(" readiness ")); let mut ls = ListState::default(); ls.select(Some(self.setup_cursor)); frame.render_stateful_widget(list, area, &mut ls); } fn render_preview(&self, frame: &mut Frame, area: Rect, p: &Preview, db: &Db) { let warmup_ids = warmup_tag_ids( &p.slots .iter() .map(|s| PickedSlot { primary: s.current().clone(), alternates: vec![], }) .collect::>(), ); let warmup_names: Vec = warmup_ids .iter() .filter_map(|id| self.tags.iter().find(|t| t.id == *id).map(|t| t.name.clone())) .collect(); let warmup_line = format!( "warmup: {}", if warmup_names.is_empty() { "-".to_string() } else { warmup_names.join(", ") } ); let mut lines: Vec = vec![ Line::from(Span::styled( warmup_line, Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), ]; for (i, slot) in p.slots.iter().enumerate() { let ex = slot.current(); let (prescription_text, glyph) = prescription_for(db, ex); let alt_marker = if slot.idx == 0 { String::new() } else { format!(" [alt {}/{}]", slot.idx, slot.alternates.len()) }; let cursor = if i == p.slot_cursor { "> " } else { " " }; let base = format!( "{cursor}{:>2}. {:<24} {} {}{}", i + 1, ex.name, prescription_text, glyph, alt_marker, ); if i == p.slot_cursor { lines.push(Line::from(Span::styled( base, Style::default().add_modifier(Modifier::REVERSED), ))); } else { lines.push(Line::from(base)); } } let block = Block::default().borders(Borders::ALL).title(" preview "); let inner = block.inner(area); frame.render_widget(block, area); frame.render_widget(Paragraph::new(lines), inner); } } fn cycle_slot(p: &mut Preview, delta: i32) { let Some(slot) = p.slots.get_mut(p.slot_cursor) else { return; }; let choices = 1 + slot.alternates.len() as i32; let next = (slot.idx as i32 + delta).rem_euclid(choices); slot.idx = next as usize; } fn prescription_for(db: &Db, ex: &Exercise) -> (String, &'static str) { match compute_prescription_any(db, ex) { Ok(AnyPrescriptionResult::NoHistory) => ("seed on first log".to_string(), " "), Ok(AnyPrescriptionResult::Prescribed(p)) => { let text = format_prescription_any(&p, ex.load_unit.as_str()); let glyph = direction_glyph(db, ex, &p); (text, glyph) } Err(_) => ("(error)".to_string(), " "), } } fn format_prescription_any(p: &AnyPrescription, unit: &str) -> String { match p { AnyPrescription::Reps(p) => format_reps_prescription(p, unit), AnyPrescription::Timed(p) => format_timed_prescription(p), // Distance is track-only; compute_prescription_any never returns // Prescribed for it today. Kept for exhaustiveness. AnyPrescription::Distance(_) => "(distance)".to_string(), } } fn format_reps_prescription(p: &Prescription, unit: &str) -> String { format!( "{}x{} @ {} {} [{}]", p.sets, p.reps, p.load, unit, state_tag(p.state), ) } fn format_timed_prescription(p: &TimedPrescription) -> String { format!("{}x {} [{}]", p.sets, p.duration, state_tag(p.state)) } fn state_tag(s: State) -> &'static str { match s { State::Progressing => "prog", State::Probation => "prob", State::Deloading => "deld", State::Rebuilding => "rebd", } } /// Direction arrow versus the most recent session for this exercise. /// Dispatches on effort kind: reps compares top load, timed compares /// top duration, distance is neutral (track-only, no notion of "up"). fn direction_glyph(db: &Db, ex: &Exercise, next: &AnyPrescription) -> &'static str { match (ex.effort_kind, next) { (EffortKind::Reps, AnyPrescription::Reps(p)) => { let sessions = RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default(); let Some(last) = sessions.last() else { return " " }; let last_top = last .iter() .map(|s| s.load.get()) .fold(f64::NEG_INFINITY, f64::max); compare(p.load.get(), last_top) } (EffortKind::Timed, AnyPrescription::Timed(p)) => { let sessions = TimedKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default(); let Some(last) = sessions.last() else { return " " }; let last_top = last.iter().map(|s| s.duration.seconds()).max().unwrap_or(0); compare(p.duration.seconds() as f64, last_top as f64) } (EffortKind::Distance, _) => " ", _ => " ", } } fn compare(next: f64, last: f64) -> &'static str { if next > last { "up" } else if next < last { "down" } else { "flat" } } #[cfg(test)] mod tests { use super::*; use crossterm::event::KeyEvent; use ripgrow_core::{ResistanceType, LoadUnit}; fn key(c: KeyCode) -> KeyEvent { KeyEvent::new(c, KeyModifiers::empty()) } fn setup() -> (Db, Vec, Vec) { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let t1 = db.upsert_tag("chest").unwrap(); let t2 = db.upsert_tag("back").unwrap(); let t3 = db.upsert_tag("legs").unwrap(); let a = db .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t1.id]) .unwrap(); let b = db .create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t2.id]) .unwrap(); let c = db .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t3.id]) .unwrap(); (db, vec![t1.id, t2.id, t3.id], vec![a, b, c]) } #[test] fn generate_produces_preview_and_writes_readiness() { let (db, _tags, _) = setup(); let mut screen = GenerateScreen::load(&db).unwrap(); // Tags come back alphabetically: back, chest, legs. Cursor starts // at 0 = back. Space cycles it to Ready. let cursor_tag_id = screen.tags[screen.setup_cursor].id; screen.on_key(&db, key(KeyCode::Char(' '))); assert_eq!( db.read_readiness(screen.date).unwrap().get(&cursor_tag_id), Some(&Readiness::Ready) ); screen.on_key(&db, key(KeyCode::Char('g'))); assert!(screen.preview.is_some()); assert!(screen.preview.as_ref().unwrap().slots.len() >= 1); } #[test] fn reroll_cycles_alternates() { let (db, tags, _) = setup(); let mut screen = GenerateScreen::load(&db).unwrap(); for t in &tags { db.set_readiness(screen.date, *t, Some(Readiness::Ready)) .unwrap(); } screen.readiness = db.read_readiness(screen.date).unwrap(); screen.on_key(&db, key(KeyCode::Char('g'))); let p = screen.preview.as_ref().unwrap(); assert!(!p.slots.is_empty()); let starting_idx = p.slots[0].idx; // Only cycle if alternates exist for the first slot. if !p.slots[0].alternates.is_empty() { screen.on_key(&db, key(KeyCode::Char('r'))); let p = screen.preview.as_ref().unwrap(); assert_ne!(p.slots[0].idx, starting_idx); } } #[test] fn d_drops_slot() { let (db, tags, _) = setup(); let mut screen = GenerateScreen::load(&db).unwrap(); for t in &tags { db.set_readiness(screen.date, *t, Some(Readiness::Ready)) .unwrap(); } screen.readiness = db.read_readiness(screen.date).unwrap(); screen.on_key(&db, key(KeyCode::Char('g'))); let n = screen.preview.as_ref().unwrap().slots.len(); screen.on_key(&db, key(KeyCode::Char('d'))); assert_eq!(screen.preview.as_ref().unwrap().slots.len(), n - 1); } #[test] fn a_adds_slot_when_pool_has_more() { let (db, tags, _) = setup(); let mut screen = GenerateScreen::load(&db).unwrap(); for t in &tags { db.set_readiness(screen.date, *t, Some(Readiness::Ready)) .unwrap(); } screen.readiness = db.read_readiness(screen.date).unwrap(); screen.count = 1; screen.on_key(&db, key(KeyCode::Char('g'))); assert_eq!(screen.preview.as_ref().unwrap().slots.len(), 1); screen.on_key(&db, key(KeyCode::Char('a'))); assert_eq!(screen.preview.as_ref().unwrap().slots.len(), 2); } #[test] fn sore_tag_blocks_exercise() { let (db, tags, _) = setup(); let mut screen = GenerateScreen::load(&db).unwrap(); // Sore back → row is excluded. db.set_readiness(screen.date, tags[1], Some(Readiness::Sore)) .unwrap(); // Ready chest + legs so we still have candidates. db.set_readiness(screen.date, tags[0], Some(Readiness::Ready)) .unwrap(); db.set_readiness(screen.date, tags[2], Some(Readiness::Ready)) .unwrap(); screen.readiness = db.read_readiness(screen.date).unwrap(); screen.on_key(&db, key(KeyCode::Char('g'))); let names: Vec = screen .preview .as_ref() .unwrap() .slots .iter() .map(|s| s.primary.name.clone()) .collect(); assert!(!names.contains(&"row".to_string())); } #[test] fn esc_returns_from_preview_to_setup() { let (db, tags, _) = setup(); let mut screen = GenerateScreen::load(&db).unwrap(); for t in &tags { db.set_readiness(screen.date, *t, Some(Readiness::Ready)) .unwrap(); } screen.readiness = db.read_readiness(screen.date).unwrap(); screen.on_key(&db, key(KeyCode::Char('g'))); assert!(screen.preview.is_some()); screen.on_key(&db, key(KeyCode::Esc)); assert!(screen.preview.is_none()); } #[test] fn prescription_for_reps_renders_load_and_state() { use ripgrow_core::{Load, RepsPayload, Reps as CoreReps, Rpe}; let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let ex_id = db .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); RepsKind::append( &db, ex_id, d, RepsPayload::new(Load::new(80.0).unwrap(), CoreReps::new(5).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap(); let (text, glyph) = prescription_for(&db, &ex); // Clean-hit first session -> +increment -> 82.5. assert!(text.contains("82.5"), "text was: {text}"); assert!(text.contains("prog"), "text was: {text}"); assert_eq!(glyph, "up"); } #[test] fn prescription_for_timed_renders_duration_and_state() { use ripgrow_core::{Duration as CoreDuration, Rpe, TimedPayload}; let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let ex_id = db .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[]) .unwrap(); let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); TimedKind::append( &db, ex_id, d, TimedPayload::new(CoreDuration::from_seconds(60).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap(); let (text, glyph) = prescription_for(&db, &ex); // Clean-hit first session -> +increment (10 sec) -> 70 sec -> "1m10s". assert!(text.contains("1m10s"), "text was: {text}"); assert!(text.contains("prog"), "text was: {text}"); assert_eq!(glyph, "up"); } #[test] fn prescription_for_distance_is_seed_regardless_of_history() { use ripgrow_core::{ Distance as CoreDistance, DistanceKind, DistancePayload, Duration as CoreDuration, Rpe, }; let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let ex_id = db .create_exercise( "5k run", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[], ) .unwrap(); let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); DistanceKind::append( &db, ex_id, d, DistancePayload::new( CoreDistance::from_meters(5000.0).unwrap(), CoreDuration::from_seconds(1500).unwrap(), ), Rpe::new(3).unwrap(), false, ) .unwrap(); let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap(); let (text, _glyph) = prescription_for(&db, &ex); // Distance is track-only — always NoHistory. assert_eq!(text, "seed on first log"); } }