Skip to main content

max / ripgrow

templates: + opens tag prompt from any field, and seed a starter library The + shortcut previously only worked when the Tags field was focused; pressing it from Name/Type/LoadUnit just typed a literal +. Now it jumps focus to Tags and opens the prompt from anywhere in the edit form, which matches what the footer hint led users to expect. seed_starter_content plants 14 tags (movement patterns + regions) and 21 exercises (barbell staples, common machines, bodyweight basics) into a fresh profile. Increments derive from resistance type and profile unit: 2.5/5 kg for freeweights, 5/10 kg for machines, 0 for bodyweight (5/10 lb equivalents when the profile is lb). Idempotent; safe to re-run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 17:51 UTC
Commit: 9705fb29e64a01244b1675c9358302d9ae13f2b8
Parent: eda6007
4 files changed, +298 insertions, -2 deletions
@@ -10,6 +10,7 @@ pub mod estimator;
10 10 pub mod generation;
11 11 pub mod profiles;
12 12 pub mod progression;
13 + pub mod seed;
13 14 pub mod sets;
14 15 pub mod templates;
15 16
@@ -20,5 +21,6 @@ pub use estimator::{estimate_e1rm, rir_from_rpe};
20 21 pub use generation::{PickedSlot, Readiness};
21 22 pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
22 23 pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State};
24 + pub use seed::seed_starter_content;
23 25 pub use sets::SessionSet;
24 26 pub use templates::{Exercise, ResistanceType, Tag};
@@ -0,0 +1,260 @@
1 + //! Starter content for a fresh profile.
2 + //!
3 + //! A common set of tags and exercises so a new profile is not empty on
4 + //! first open. The user is free to edit or delete anything here; the seed
5 + //! runs once at profile creation and is never re-applied.
6 +
7 + use crate::db::{Db, Unit};
8 + use crate::error::Error;
9 + use crate::templates::ResistanceType;
10 +
11 + struct SeedExercise {
12 + name: &'static str,
13 + resistance_type: ResistanceType,
14 + tags: &'static [&'static str],
15 + }
16 +
17 + const SEED_TAGS: &[&str] = &[
18 + "push",
19 + "pull",
20 + "squat",
21 + "hinge",
22 + "chest",
23 + "back",
24 + "shoulders",
25 + "biceps",
26 + "triceps",
27 + "quads",
28 + "hamstrings",
29 + "glutes",
30 + "calves",
31 + "core",
32 + ];
33 +
34 + const SEED_EXERCISES: &[SeedExercise] = &[
35 + // Barbell staples.
36 + SeedExercise {
37 + name: "back squat",
38 + resistance_type: ResistanceType::Freeweight,
39 + tags: &["squat", "quads", "glutes", "core"],
40 + },
41 + SeedExercise {
42 + name: "front squat",
43 + resistance_type: ResistanceType::Freeweight,
44 + tags: &["squat", "quads", "core"],
45 + },
46 + SeedExercise {
47 + name: "deadlift",
48 + resistance_type: ResistanceType::Freeweight,
49 + tags: &["hinge", "back", "hamstrings", "glutes"],
50 + },
51 + SeedExercise {
52 + name: "romanian deadlift",
53 + resistance_type: ResistanceType::Freeweight,
54 + tags: &["hinge", "hamstrings", "glutes"],
55 + },
56 + SeedExercise {
57 + name: "bench press",
58 + resistance_type: ResistanceType::Freeweight,
59 + tags: &["push", "chest", "triceps", "shoulders"],
60 + },
61 + SeedExercise {
62 + name: "incline bench press",
63 + resistance_type: ResistanceType::Freeweight,
64 + tags: &["push", "chest", "shoulders", "triceps"],
65 + },
66 + SeedExercise {
67 + name: "overhead press",
68 + resistance_type: ResistanceType::Freeweight,
69 + tags: &["push", "shoulders", "triceps"],
70 + },
71 + SeedExercise {
72 + name: "barbell row",
73 + resistance_type: ResistanceType::Freeweight,
74 + tags: &["pull", "back", "biceps"],
75 + },
76 + // Machines.
77 + SeedExercise {
78 + name: "lat pulldown",
79 + resistance_type: ResistanceType::Machine,
80 + tags: &["pull", "back", "biceps"],
81 + },
82 + SeedExercise {
83 + name: "seated row",
84 + resistance_type: ResistanceType::Machine,
85 + tags: &["pull", "back", "biceps"],
86 + },
87 + SeedExercise {
88 + name: "leg press",
89 + resistance_type: ResistanceType::Machine,
90 + tags: &["squat", "quads", "glutes"],
91 + },
92 + SeedExercise {
93 + name: "leg curl",
94 + resistance_type: ResistanceType::Machine,
95 + tags: &["hamstrings"],
96 + },
97 + SeedExercise {
98 + name: "leg extension",
99 + resistance_type: ResistanceType::Machine,
100 + tags: &["quads"],
101 + },
102 + SeedExercise {
103 + name: "calf raise",
104 + resistance_type: ResistanceType::Machine,
105 + tags: &["calves"],
106 + },
107 + SeedExercise {
108 + name: "tricep pushdown",
109 + resistance_type: ResistanceType::Machine,
110 + tags: &["push", "triceps"],
111 + },
112 + SeedExercise {
113 + name: "cable curl",
114 + resistance_type: ResistanceType::Machine,
115 + tags: &["pull", "biceps"],
116 + },
117 + // Bodyweight.
118 + SeedExercise {
119 + name: "pull-up",
120 + resistance_type: ResistanceType::Bodyweight,
121 + tags: &["pull", "back", "biceps"],
122 + },
123 + SeedExercise {
124 + name: "chin-up",
125 + resistance_type: ResistanceType::Bodyweight,
126 + tags: &["pull", "back", "biceps"],
127 + },
128 + SeedExercise {
129 + name: "dip",
130 + resistance_type: ResistanceType::Bodyweight,
131 + tags: &["push", "chest", "triceps"],
132 + },
133 + SeedExercise {
134 + name: "push-up",
135 + resistance_type: ResistanceType::Bodyweight,
136 + tags: &["push", "chest", "triceps"],
137 + },
138 + SeedExercise {
139 + name: "hanging leg raise",
140 + resistance_type: ResistanceType::Bodyweight,
141 + tags: &["core"],
142 + },
143 + ];
144 +
145 + /// Insert the starter tag vocabulary and exercise library into a fresh
146 + /// profile. Idempotent: pre-existing tags are reused via `upsert_tag`;
147 + /// pre-existing exercises by name are skipped (`UNIQUE` constraint on
148 + /// `exercises.name` would otherwise error). Load unit and increment are
149 + /// derived from the profile's `Unit`.
150 + pub fn seed_starter_content(db: &Db, unit: Unit) -> Result<(), Error> {
151 + for tag in SEED_TAGS {
152 + db.upsert_tag(tag)?;
153 + }
154 + let existing = db.list_exercises()?;
155 + let unit_str = unit.as_str();
156 + for seed in SEED_EXERCISES {
157 + if existing.iter().any(|e| e.name == seed.name) {
158 + continue;
159 + }
160 + let tag_ids = seed
161 + .tags
162 + .iter()
163 + .map(|name| Ok(db.upsert_tag(name)?.id))
164 + .collect::<Result<Vec<_>, Error>>()?;
165 + let increment = increment_for(seed.resistance_type, unit);
166 + db.create_exercise(
167 + seed.name,
168 + seed.resistance_type,
169 + unit_str,
170 + increment,
171 + &tag_ids,
172 + )?;
173 + }
174 + Ok(())
175 + }
176 +
177 + fn increment_for(rt: ResistanceType, unit: Unit) -> f64 {
178 + match rt {
179 + ResistanceType::Bodyweight => 0.0,
180 + ResistanceType::CardioTime | ResistanceType::CardioDistance => 0.0,
181 + ResistanceType::Freeweight => match unit {
182 + Unit::Kg => 2.5,
183 + Unit::Lb => 5.0,
184 + },
185 + ResistanceType::Machine => match unit {
186 + Unit::Kg => 5.0,
187 + Unit::Lb => 10.0,
188 + },
189 + }
190 + }
191 +
192 + #[cfg(test)]
193 + mod tests {
194 + use super::*;
195 +
196 + fn fresh() -> Db {
197 + let db = Db::open_in_memory().unwrap();
198 + db.init_profile("self", Unit::Kg).unwrap();
199 + db
200 + }
201 +
202 + #[test]
203 + fn seed_creates_expected_counts() {
204 + let db = fresh();
205 + seed_starter_content(&db, Unit::Kg).unwrap();
206 + assert_eq!(db.list_tags().unwrap().len(), SEED_TAGS.len());
207 + assert_eq!(db.list_exercises().unwrap().len(), SEED_EXERCISES.len());
208 + }
209 +
210 + #[test]
211 + fn seed_is_idempotent_when_run_twice() {
212 + let db = fresh();
213 + seed_starter_content(&db, Unit::Kg).unwrap();
214 + seed_starter_content(&db, Unit::Kg).unwrap();
215 + assert_eq!(db.list_exercises().unwrap().len(), SEED_EXERCISES.len());
216 + }
217 +
218 + #[test]
219 + fn seed_respects_unit_for_increments() {
220 + let db = fresh();
221 + seed_starter_content(&db, Unit::Lb).unwrap();
222 + let squat = db
223 + .list_exercises()
224 + .unwrap()
225 + .into_iter()
226 + .find(|e| e.name == "back squat")
227 + .unwrap();
228 + assert_eq!(squat.load_unit, "lb");
229 + assert_eq!(squat.increment, 5.0);
230 +
231 + let leg_press = db
232 + .list_exercises()
233 + .unwrap()
234 + .into_iter()
235 + .find(|e| e.name == "leg press")
236 + .unwrap();
237 + assert_eq!(leg_press.increment, 10.0);
238 +
239 + let pullup = db
240 + .list_exercises()
241 + .unwrap()
242 + .into_iter()
243 + .find(|e| e.name == "pull-up")
244 + .unwrap();
245 + assert_eq!(pullup.increment, 0.0);
246 + }
247 +
248 + #[test]
249 + fn seed_wires_up_tags() {
250 + let db = fresh();
251 + seed_starter_content(&db, Unit::Kg).unwrap();
252 + let squat = db
253 + .list_exercises()
254 + .unwrap()
255 + .into_iter()
256 + .find(|e| e.name == "back squat")
257 + .unwrap();
258 + assert_eq!(squat.tag_ids.len(), 4);
259 + }
260 + }
@@ -12,7 +12,9 @@ use ratatui::Frame;
12 12 use ratatui::style::{Modifier, Style};
13 13 use ratatui::text::{Line, Span};
14 14 use ratatui::widgets::{Block, Borders, Tabs};
15 - use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir};
15 + use ripgrow_core::{
16 + Db, Unit, create_profile_in, list_profiles, profiles_dir, seed_starter_content,
17 + };
16 18
17 19 use crate::screens::diagnostic::DiagnosticScreen;
18 20 use crate::screens::generate::GenerateScreen;
@@ -282,6 +284,10 @@ impl App {
282 284 };
283 285 match create_profile_in(&dir, display_name, unit) {
284 286 Ok((profile, db)) => {
287 + if let Err(e) = seed_starter_content(&db, unit) {
288 + self.status = format!("seed failed: {e}");
289 + return;
290 + }
285 291 let templates = match TemplatesScreen::load(&db) {
286 292 Ok(s) => s,
287 293 Err(e) => {
@@ -128,6 +128,14 @@ impl TemplatesScreen {
128 128 form.cycle_focus(-1);
129 129 (Mode::Edit(form), TemplatesAction::None)
130 130 }
131 + // `+` opens the tag-add prompt from any field. When the prompt
132 + // is already open it falls through so the buffer can accept the
133 + // literal character (unlikely for tag names but not forbidden).
134 + (KeyCode::Char('+'), _) if form.new_tag_buffer.is_none() => {
135 + form.focus = Field::Tags;
136 + form.new_tag_buffer = Some(String::new());
137 + (Mode::Edit(form), TemplatesAction::None)
138 + }
131 139 _ => {
132 140 if let Some(refresh) = form.on_field_key(db, key) {
133 141 match refresh {
@@ -201,7 +209,7 @@ impl TemplatesScreen {
201 209 // Footer.
202 210 let hint = match &self.mode {
203 211 Mode::Browse => "j/k move n new e edit d delete q quit",
204 - Mode::Edit(_) => "tab/shift-tab move space toggle + add tag ctrl-s save esc cancel",
212 + Mode::Edit(_) => "tab/shift-tab move + new tag (any field) space toggle ctrl-s save esc cancel",
205 213 };
206 214 frame.render_widget(
207 215 Paragraph::new(Line::from(vec![
@@ -609,6 +617,26 @@ mod tests {
609 617 }
610 618
611 619 #[test]
620 + fn plus_from_any_field_opens_tag_prompt_and_jumps_focus() {
621 + let db = fresh_db();
622 + let mut screen = TemplatesScreen::load(&db).unwrap();
623 + screen.on_key(&db, key(KeyCode::Char('n')));
624 + // Focus is Name. Press + directly.
625 + screen.on_key(&db, key(KeyCode::Char('+')));
626 + // Prompt should be open and focus should have jumped to Tags.
627 + let Mode::Edit(form) = &screen.mode else {
628 + panic!("expected edit mode");
629 + };
630 + assert!(form.new_tag_buffer.is_some());
631 + assert_eq!(form.focus, Field::Tags);
632 + for c in "pull".chars() {
633 + screen.on_key(&db, key(KeyCode::Char(c)));
634 + }
635 + screen.on_key(&db, key(KeyCode::Enter));
636 + assert_eq!(db.list_tags().unwrap()[0].name, "pull");
637 + }
638 +
639 + #[test]
612 640 fn delete_removes_selected_exercise() {
613 641 let db = fresh_db();
614 642 db.create_exercise("a", ResistanceType::Machine, "kg", 5.0, &[])