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, +258 insertions, -73 deletions
@@ -25,10 +25,61 @@ pub struct SessionExport {
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 @@ pub fn export_to_pdf(
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 @@ fn render_json(session: &SessionExport) -> String {
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,33 +284,62 @@ mod tests {
232 284 }
233 285
234 286 #[test]
235 - fn format_prescription_freeweight() {
236 - let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 82.5));
237 - assert_eq!(s, "3 x 5 @ 82.5 kg");
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");
238 305 }
239 306
240 307 #[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");
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, "");
244 315 }
245 316
246 317 #[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");
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");
250 325 }
251 326
252 327 #[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");
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");
256 337 }
257 338
258 339 #[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");
340 + fn format_prescription_still_composes_a_one_liner() {
341 + let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 82.5));
342 + assert_eq!(s, "3 x 5 @ 82.5 kg");
262 343 }
263 344
264 345 #[test]
@@ -269,11 +350,14 @@ mod tests {
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 @@ mod tests {
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 @@ mod tests {
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 ],
@@ -22,7 +22,7 @@ use ripgrow_core::{
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 @@ impl GenerateScreen {
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 @@ impl GenerateScreen {
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 }
@@ -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]
24 - ]
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 + )
25 37
26 - #v(1em)
38 + #v(0.25em)
39 + #line(length: 100%, stroke: rule-heavy + ink)
40 + #v(0.6em)
27 41
28 - #if data.warmup.len() > 0 [
29 - *warmup:* #data.warmup.join(", ")
30 - ] else [
31 - *warmup:* —
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 + )
32 65 ]
33 66
34 - #v(1em)
67 + #v(0.6em)
68 +
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.
74 +
75 + #let header-cell(body) = text(
76 + size: 8pt,
77 + weight: "bold",
78 + tracking: 2pt,
79 + fill: dim,
80 + )[#body]
81 +
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)]
88 +
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"),
35 109
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] ]
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 + )
42 120
43 - #v(0.5em)
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 ]