| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
|
],
|