Skip to main content

max / ripgrow

pdf: redesign as a technical session sheet The single-line prescription reads fine on a monitor but not from the floor mid-set. Redesign as a lab-sheet-style table where load is the visual anchor. SlotExport decomposes into name / sets / reps / load / unit / glyph so the template can align numeric columns. PrescriptionParts holds the same shape as a small conversion helper (Freeweight/Machine to `sets reps load unit`; Bodyweight collapses load to `BW` or `BW+delta`; cardio flattens sets/reps to `-` and treats load as duration or distance). format_prescription still composes a one-liner for the on-screen preview. New sheet.typ pulls in the technical-drawing feel: heavy tracked masthead with title, profile, date; a shaded warmup band; a table with heavy top and bottom rules, light row rules, letterspaced small-caps headers. Column type sizes are graded so LOAD is 22pt bold — the number you check between sets — while sets, reps, and row index sit at 16pt and the exercise name at 15pt medium. Unit and vs-last are muted so they don't compete. Generate screen's export path fills the decomposed fields directly from PrescriptionParts, so rerolls, cardio, and bodyweight all end up in the right columns. 85 tests total (49 core + 36 tui), all passing including the real end-to-end typst render.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 17:15 UTC
Commit: 29b0404ff42edadbfff2cd71a0da3ec5d69b4215
Parent: e21648c
3 files changed, +268 insertions, -83 deletions
@@ -25,10 +25,61 @@
25 25 #[derive(Debug, Clone, PartialEq)]
26 26 pub struct SlotExport {
27 27 pub name: String,
28 - pub prescription: String,
28 + /// Set count as a string. `"-"` when the exercise is cardio and set
29 + /// count doesn't apply.
30 + pub sets: String,
31 + /// Target reps per set. `"-"` for cardio.
32 + pub reps: String,
33 + /// Load number as a string. `"BW"` for bodyweight-only exercises,
34 + /// `"BW+10"` for bodyweight with added load, `"82.5"` for freeweight
35 + /// or cardio.
36 + pub load: String,
37 + /// Unit for the load, if any. Empty string for bodyweight-only.
38 + pub unit: String,
39 + /// `"up"` / `"flat"` / `"down"` or empty when there's no prior session.
29 40 pub glyph: String,
30 41 }
31 42
43 + /// Decomposed prescription, ready for a tabular layout.
44 + #[derive(Debug, Clone, PartialEq)]
45 + pub struct PrescriptionParts {
46 + pub sets: String,
47 + pub reps: String,
48 + pub load: String,
49 + pub unit: String,
50 + }
51 +
52 + impl PrescriptionParts {
53 + pub fn from(ex: &Exercise, p: &Prescription) -> Self {
54 + match ex.resistance_type {
55 + ResistanceType::Bodyweight if p.load == 0.0 => Self {
56 + sets: p.sets.to_string(),
57 + reps: p.reps.to_string(),
58 + load: "BW".to_string(),
59 + unit: String::new(),
60 + },
61 + ResistanceType::Bodyweight => Self {
62 + sets: p.sets.to_string(),
63 + reps: p.reps.to_string(),
64 + load: format!("BW+{}", trim(p.load)),
65 + unit: ex.load_unit.clone(),
66 + },
67 + ResistanceType::CardioTime | ResistanceType::CardioDistance => Self {
68 + sets: "-".to_string(),
69 + reps: "-".to_string(),
70 + load: trim(p.load),
71 + unit: ex.load_unit.clone(),
72 + },
73 + ResistanceType::Freeweight | ResistanceType::Machine => Self {
74 + sets: p.sets.to_string(),
75 + reps: p.reps.to_string(),
76 + load: trim(p.load),
77 + unit: ex.load_unit.clone(),
78 + },
79 + }
80 + }
81 + }
82 +
32 83 #[derive(Debug, thiserror::Error)]
33 84 pub enum ExportError {
34 85 #[error("typst not on PATH — install it (e.g. `brew install typst`)")]
@@ -91,22 +142,20 @@
91 142 Ok(output_path)
92 143 }
93 144
94 - /// Format the numeric prescription per the design note: freeweight/machine
95 - /// as `S x R @ L unit`; bodyweight collapses when load is zero; cardio
96 - /// renders load with its unit (min/km).
145 + /// Format the numeric prescription as a single line for the on-screen
146 + /// preview. The PDF template consumes the decomposed [`PrescriptionParts`]
147 + /// instead so the tabular layout can align columns.
97 148 pub fn format_prescription(ex: &Exercise, p: &Prescription) -> String {
149 + let parts = PrescriptionParts::from(ex, p);
150 + let (sets, reps, load, unit) = (parts.sets, parts.reps, parts.load, parts.unit);
98 151 match ex.resistance_type {
99 - ResistanceType::Bodyweight if p.load == 0.0 => {
100 - format!("{} x {}", p.sets, p.reps)
101 - }
102 - ResistanceType::Bodyweight => {
103 - format!("{} x {} + {} {}", p.sets, p.reps, trim(p.load), ex.load_unit)
104 - }
152 + ResistanceType::Bodyweight if p.load == 0.0 => format!("{sets} x {reps}"),
153 + ResistanceType::Bodyweight => format!("{sets} x {reps} {load} {unit}"),
105 154 ResistanceType::CardioTime | ResistanceType::CardioDistance => {
106 - format!("{} {}", trim(p.load), ex.load_unit)
155 + format!("{load} {unit}")
107 156 }
108 157 ResistanceType::Freeweight | ResistanceType::Machine => {
109 - format!("{} x {} @ {} {}", p.sets, p.reps, trim(p.load), ex.load_unit)
158 + format!("{sets} x {reps} @ {load} {unit}")
110 159 }
111 160 }
112 161 }
@@ -142,7 +191,10 @@
142 191 }
143 192 out.push('{');
144 193 out.push_str(&format!("\"name\":{},", escape(&s.name)));
145 - out.push_str(&format!("\"prescription\":{},", escape(&s.prescription)));
194 + out.push_str(&format!("\"sets\":{},", escape(&s.sets)));
195 + out.push_str(&format!("\"reps\":{},", escape(&s.reps)));
196 + out.push_str(&format!("\"load\":{},", escape(&s.load)));
197 + out.push_str(&format!("\"unit\":{},", escape(&s.unit)));
146 198 out.push_str(&format!("\"glyph\":{}", escape(&s.glyph)));
147 199 out.push('}');
148 200 }
@@ -232,35 +284,64 @@
232 284 }
233 285
234 286 #[test]
235 - fn format_prescription_freeweight() {
287 + fn parts_freeweight() {
288 + let parts = PrescriptionParts::from(
289 + &ex("bench", ResistanceType::Freeweight, "kg"),
290 + &p(3, 5, 82.5),
291 + );
292 + assert_eq!(parts.sets, "3");
293 + assert_eq!(parts.reps, "5");
294 + assert_eq!(parts.load, "82.5");
295 + assert_eq!(parts.unit, "kg");
296 + }
297 +
298 + #[test]
299 + fn parts_trims_integer_loads() {
300 + let parts = PrescriptionParts::from(
301 + &ex("bench", ResistanceType::Freeweight, "kg"),
302 + &p(3, 5, 80.0),
303 + );
304 + assert_eq!(parts.load, "80");
305 + }
306 +
307 + #[test]
308 + fn parts_bodyweight_zero_load() {
309 + let parts = PrescriptionParts::from(
310 + &ex("pullup", ResistanceType::Bodyweight, "kg"),
311 + &p(3, 8, 0.0),
312 + );
313 + assert_eq!(parts.load, "BW");
314 + assert_eq!(parts.unit, "");
315 + }
316 +
317 + #[test]
318 + fn parts_bodyweight_with_added_load() {
319 + let parts = PrescriptionParts::from(
320 + &ex("pullup", ResistanceType::Bodyweight, "kg"),
321 + &p(3, 5, 10.0),
322 + );
323 + assert_eq!(parts.load, "BW+10");
324 + assert_eq!(parts.unit, "kg");
325 + }
326 +
327 + #[test]
328 + fn parts_cardio() {
329 + let parts = PrescriptionParts::from(
330 + &ex("row", ResistanceType::CardioTime, "min"),
331 + &p(1, 1, 12.0),
332 + );
333 + assert_eq!(parts.sets, "-");
334 + assert_eq!(parts.reps, "-");
335 + assert_eq!(parts.load, "12");
336 + assert_eq!(parts.unit, "min");
337 + }
338 +
339 + #[test]
340 + fn format_prescription_still_composes_a_one_liner() {
236 341 let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 82.5));
237 342 assert_eq!(s, "3 x 5 @ 82.5 kg");
238 343 }
239 344
240 - #[test]
241 - fn format_prescription_trims_integer_loads() {
242 - let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 80.0));
243 - assert_eq!(s, "3 x 5 @ 80 kg");
244 - }
245 -
246 - #[test]
247 - fn format_prescription_bodyweight_zero_load() {
248 - let s = format_prescription(&ex("pullup", ResistanceType::Bodyweight, "kg"), &p(3, 8, 0.0));
249 - assert_eq!(s, "3 x 8");
250 - }
251 -
252 - #[test]
253 - fn format_prescription_bodyweight_with_added_load() {
254 - let s = format_prescription(&ex("pullup", ResistanceType::Bodyweight, "kg"), &p(3, 5, 10.0));
255 - assert_eq!(s, "3 x 5 + 10 kg");
256 - }
257 -
258 - #[test]
259 - fn format_prescription_cardio() {
260 - let s = format_prescription(&ex("row", ResistanceType::CardioTime, "min"), &p(1, 1, 12.0));
261 - assert_eq!(s, "12 min");
262 - }
263 -
264 345 #[test]
265 346 fn render_json_shape_is_stable() {
266 347 let s = SessionExport {
@@ -269,11 +350,14 @@
269 350 warmup: vec!["chest".into(), "shoulders".into()],
270 351 slots: vec![SlotExport {
271 352 name: "bench".into(),
272 - prescription: "3 x 5 @ 82.5 kg".into(),
353 + sets: "3".into(),
354 + reps: "5".into(),
355 + load: "82.5".into(),
356 + unit: "kg".into(),
273 357 glyph: "up".into(),
274 358 }],
275 359 };
276 - let expected = r#"{"profile":"self","date":"2026-07-18","warmup":["chest","shoulders"],"slots":[{"name":"bench","prescription":"3 x 5 @ 82.5 kg","glyph":"up"}]}"#;
360 + let expected = r#"{"profile":"self","date":"2026-07-18","warmup":["chest","shoulders"],"slots":[{"name":"bench","sets":"3","reps":"5","load":"82.5","unit":"kg","glyph":"up"}]}"#;
277 361 assert_eq!(render_json(&s), expected);
278 362 }
279 363
@@ -285,7 +369,10 @@
285 369 warmup: vec![],
286 370 slots: vec![SlotExport {
287 371 name: "back\\slash".into(),
288 - prescription: "1 x 1".into(),
372 + sets: "1".into(),
373 + reps: "1".into(),
374 + load: "0".into(),
375 + unit: "".into(),
289 376 glyph: "".into(),
290 377 }],
291 378 };
@@ -315,17 +402,26 @@
315 402 slots: vec![
316 403 SlotExport {
317 404 name: "bench".into(),
318 - prescription: "3 x 5 @ 82.5 kg".into(),
405 + sets: "3".into(),
406 + reps: "5".into(),
407 + load: "82.5".into(),
408 + unit: "kg".into(),
319 409 glyph: "up".into(),
320 410 },
321 411 SlotExport {
322 412 name: "pullup".into(),
323 - prescription: "3 x 8".into(),
413 + sets: "3".into(),
414 + reps: "8".into(),
415 + load: "BW".into(),
416 + unit: "".into(),
324 417 glyph: "flat".into(),
325 418 },
326 419 SlotExport {
327 420 name: "row \"paused\"".into(),
328 - prescription: "3 x 6 @ 60 kg".into(),
421 + sets: "3".into(),
422 + reps: "6".into(),
423 + load: "60".into(),
424 + unit: "kg".into(),
329 425 glyph: "".into(),
330 426 },
331 427 ],
@@ -1,44 +1,124 @@
1 - // ripgrow session sheet. Rendered via `typst compile --input
2 - // data-path=<path> sheet.typ output.pdf` where <path> points at a JSON file
3 - // with shape:
1 + // ripgrow session sheet — technical-drawing feel. Dense enough to read
2 + // from the floor mid-lift, with the load number given the most weight.
4 3 //
5 - // {
6 - // "profile": "self",
7 - // "date": "2026-07-18",
8 - // "warmup": ["chest", "shoulders"],
9 - // "slots": [
10 - // {"name": "bench", "prescription": "3 x 5 @ 82 kg", "glyph": "up"},
11 - // ...
12 - // ]
13 - // }
4 + // Layout: masthead across the top with title + profile + date; a warmup
5 + // call-out band; a wide bordered table where each row is a slot with big
6 + // numeric SETS / REPS / LOAD columns.
7 + //
8 + // Invoked as `typst compile --input data-path=data.json sheet.typ out.pdf`.
9 + // Data schema: see pdf.rs render_json.
14 10
15 11 #let data = json(sys.inputs.at("data-path"))
16 12
17 - #set page(paper: "us-letter", margin: 0.75in)
13 + #set page(paper: "us-letter", margin: (top: 0.55in, bottom: 0.55in, x: 0.55in))
18 14 #set text(font: "Helvetica", size: 11pt)
19 15
20 - #align(center)[
21 - #text(size: 16pt, weight: "bold")[ripgrow]
22 - #v(0.2em)
23 - #text(size: 10pt)[#data.profile — #data.date]
16 + #let rule-heavy = 1.5pt
17 + #let rule-light = 0.4pt
18 + #let ink = rgb("#0f0f0f")
19 + #let dim = rgb("#585858")
20 + #let band = luma(238)
21 +
22 + // -- masthead ---------------------------------------------------------------
23 + #grid(
24 + columns: (1fr, auto),
25 + align: (left + bottom, right + bottom),
26 + [
27 + #text(size: 26pt, weight: "black", tracking: 3pt, fill: ink)[RIPGROW]
28 + #v(-0.35em)
29 + #text(size: 8pt, tracking: 2pt, fill: dim)[SESSION SHEET / TRAINING PROGRAM]
30 + ],
31 + [
32 + #text(size: 12pt, weight: "bold", tracking: 1pt, fill: ink)[#upper(data.profile)]
33 + #v(-0.35em)
34 + #text(size: 10pt, tracking: 1pt, fill: dim)[#data.date]
35 + ]
36 + )
37 +
38 + #v(0.25em)
39 + #line(length: 100%, stroke: rule-heavy + ink)
40 + #v(0.6em)
41 +
42 + // -- warmup band ------------------------------------------------------------
43 + #block(
44 + fill: band,
45 + inset: (x: 10pt, y: 8pt),
46 + width: 100%,
47 + )[
48 + #grid(
49 + columns: (auto, 1fr),
50 + align: (left + horizon, left + horizon),
51 + column-gutter: 14pt,
52 + [
53 + #text(size: 8pt, weight: "bold", tracking: 2pt, fill: dim)[WARMUP]
54 + ],
55 + [
56 + #text(size: 12pt, weight: "medium", fill: ink)[
57 + #if data.warmup.len() > 0 [
58 + #data.warmup.join(" • ")
59 + ] else [
60 +
61 + ]
62 + ]
63 + ],
64 + )
24 65 ]
25 66
26 - #v(1em)
67 + #v(0.6em)
27 68
28 - #if data.warmup.len() > 0 [
29 - *warmup:* #data.warmup.join(", ")
30 - ] else [
31 - *warmup:* —
32 - ]
69 + // -- main table -------------------------------------------------------------
70 + //
71 + // Columns: #, exercise, sets, reps, load, unit, vs. Rules are heavy at the
72 + // top and bottom, light between rows. LOAD gets the largest type since that
73 + // is what the lifter checks between working sets.
33 74
34 - #v(1em)
75 + #let header-cell(body) = text(
76 + size: 8pt,
77 + weight: "bold",
78 + tracking: 2pt,
79 + fill: dim,
80 + )[#body]
35 81
36 - #for (i, slot) in data.slots.enumerate() [
37 - #text(weight: "bold")[#(i + 1). #slot.name]
38 - #h(1em)
39 - #slot.prescription
40 - #h(1em)
41 - #if slot.glyph != "" [ #text(style: "italic")[#slot.glyph] ]
82 + #let idx-cell(body) = text(size: 16pt, weight: "bold", fill: ink)[#body]
83 + #let name-cell(body) = text(size: 15pt, weight: "medium", fill: ink)[#body]
84 + #let num-cell(body) = text(size: 16pt, weight: "medium", fill: ink)[#body]
85 + #let load-cell(body) = text(size: 22pt, weight: "bold", fill: ink)[#body]
86 + #let unit-cell(body) = text(size: 10pt, weight: "medium", tracking: 1pt, fill: dim)[#body]
87 + #let vs-cell(body) = text(size: 9pt, tracking: 1pt, fill: dim)[#upper(body)]
42 88
43 - #v(0.5em)
89 + #table(
90 + columns: (28pt, 1fr, 44pt, 44pt, 84pt, 44pt, 52pt),
91 + align: (center + horizon, left + horizon, right + horizon, right + horizon, right + horizon, left + horizon, right + horizon),
92 + stroke: (x, y) => (
93 + top: if y == 0 { rule-heavy + ink }
94 + else if y == 1 { rule-heavy + ink }
95 + else { rule-light + dim },
96 + bottom: if y == data.slots.len() { rule-heavy + ink } else { none },
97 + left: none,
98 + right: none,
99 + ),
100 + inset: (x: 6pt, y: 10pt),
101 +
102 + header-cell("#"),
103 + header-cell("EXERCISE"),
104 + header-cell("SETS"),
105 + header-cell("REPS"),
106 + header-cell("LOAD"),
107 + header-cell("UNIT"),
108 + header-cell("VS LAST"),
109 +
110 + ..data.slots.enumerate().map(((i, slot)) => (
111 + idx-cell(str(i + 1)),
112 + name-cell(slot.name),
113 + num-cell(slot.sets),
114 + num-cell(slot.reps),
115 + load-cell(slot.load),
116 + unit-cell(slot.unit),
117 + vs-cell(slot.glyph),
118 + )).flatten()
119 + )
120 +
121 + #v(0.5em)
122 + #text(size: 7pt, tracking: 2pt, fill: dim)[
123 + RIPGROW · #data.profile · #data.date · #str(data.slots.len()) SLOT#if data.slots.len() != 1 [S]
44 124 ]
@@ -22,7 +22,7 @@
22 22 Db, Exercise, Prescription, PrescriptionResult, Readiness, State, Tag,
23 23 };
24 24
25 - use crate::pdf::{self, SessionExport, SlotExport};
25 + use crate::pdf::{self, PrescriptionParts, SessionExport, SlotExport};
26 26
27 27 pub struct GenerateScreen {
28 28 date: NaiveDate,
@@ -249,15 +249,21 @@
249 249 let mut slots: Vec<SlotExport> = Vec::new();
250 250 for slot in &p.slots {
251 251 let ex = slot.current();
252 - let (prescription, glyph) = match db.compute_prescription(ex.id) {
252 + let (parts, glyph) = match db.compute_prescription(ex.id) {
253 253 Ok(PrescriptionResult::Prescribed(pres)) => {
254 - let text = pdf::format_prescription(ex, &pres);
254 + let parts = PrescriptionParts::from(ex, &pres);
255 255 let g = direction_glyph(db, ex, pres.load);
256 - (text, g.to_string())
257 - }
258 - Ok(PrescriptionResult::NoHistory) => {
259 - ("seed on first log".to_string(), String::new())
256 + (parts, g.to_string())
260 257 }
258 + Ok(PrescriptionResult::NoHistory) => (
259 + PrescriptionParts {
260 + sets: "-".to_string(),
261 + reps: "-".to_string(),
262 + load: "seed".to_string(),
263 + unit: String::new(),
264 + },
265 + String::new(),
266 + ),
261 267 Err(e) => {
262 268 self.status = format!("prescription failed: {e}");
263 269 return;
@@ -265,7 +271,10 @@
265 271 };
266 272 slots.push(SlotExport {
267 273 name: ex.name.clone(),
268 - prescription,
274 + sets: parts.sets,
275 + reps: parts.reps,
276 + load: parts.load,
277 + unit: parts.unit,
269 278 glyph,
270 279 });
271 280 }