1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use ts_rs::TS;
4
5use crate::lap::Lap;
6use crate::node::Node;
7use crate::stream::Streams;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
10#[ts(export, export_to = "../../../bindings/napi/generated/")]
11pub struct Workout {
12 pub id: String,
13 pub version: String,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub sport: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub date: Option<String>,
18 pub root: Node,
19
20 #[serde(skip_serializing_if = "Option::is_none")]
23 pub streams: Option<Streams>,
24
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub laps: Vec<Lap>,
29
30 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub metadata: BTreeMap<String, serde_json::Value>,
32}
33
34impl Workout {
35 pub const SCHEMA_VERSION: &str = "1.1.0";
36
37 pub fn new(id: impl Into<String>, root: Node) -> Self {
38 Self {
39 id: id.into(),
40 version: Self::SCHEMA_VERSION.into(),
41 sport: None,
42 date: None,
43 root,
44 streams: None,
45 laps: vec![],
46 metadata: BTreeMap::new(),
47 }
48 }
49}
50
51#[cfg(test)]
52mod tests {
53 use super::*;
54 use crate::execution_mode::ExecutionMode;
55 use crate::measure::{Measure, WeightUnit};
56 use crate::node::*;
57
58 fn make_simple_workout() -> Workout {
59 let set = Node {
60 id: NodeId::from_string("s1"),
61 kind: NodeKind::Set,
62 name: None,
63 children: vec![],
64 payload: NodePayload::Leaf {
65 measures: vec![
66 Measure::Weight { amount: 60.0, unit: WeightUnit::Kg },
67 Measure::Reps(10),
68 ],
69 intensity: None,
70 },
71 metadata: BTreeMap::new(),
72 };
73
74 let exercise = Node {
75 id: NodeId::from_string("e1"),
76 kind: NodeKind::Exercise,
77 name: Some("Squat".into()),
78 children: vec![set],
79 payload: NodePayload::Exercise {
80 measures: vec![],
81 intensity: None,
82 rest_seconds: Some(120.0),
83 },
84 metadata: BTreeMap::new(),
85 };
86
87 let block = Node {
88 id: NodeId::from_string("b1"),
89 kind: NodeKind::Block,
90 name: None,
91 children: vec![exercise],
92 payload: NodePayload::Block {
93 execution_mode: ExecutionMode::Sequential,
94 rest_seconds: None,
95 },
96 metadata: BTreeMap::new(),
97 };
98
99 let session = Node {
100 id: NodeId::from_string("sess1"),
101 kind: NodeKind::Session,
102 name: Some("Leg Day".into()),
103 children: vec![block],
104 payload: NodePayload::Temporal { rest_seconds: None },
105 metadata: BTreeMap::new(),
106 };
107
108 let mut w = Workout::new("w1", session);
109 w.sport = Some("strength".into());
110 w.date = Some("2026-03-29".into());
111 w
112 }
113
114 #[test]
115 fn workout_round_trip() {
116 let w = make_simple_workout();
117 let json = serde_json::to_string_pretty(&w).unwrap();
118 let back: Workout = serde_json::from_str(&json).unwrap();
119 assert_eq!(back.id, "w1");
120 assert_eq!(back.version, "1.1.0");
121 assert_eq!(back.sport, Some("strength".into()));
122 assert_eq!(back.root.name, Some("Leg Day".into()));
123 assert!(back.streams.is_none());
124 assert!(back.laps.is_empty());
125 }
126
127 #[test]
128 fn workout_default_version() {
129 let node = Node {
130 id: NodeId::from_string("r"),
131 kind: NodeKind::Session,
132 name: None,
133 children: vec![],
134 payload: NodePayload::Temporal { rest_seconds: None },
135 metadata: BTreeMap::new(),
136 };
137 let w = Workout::new("test", node);
138 assert_eq!(w.version, "1.1.0");
139 assert!(w.streams.is_none());
140 assert!(w.laps.is_empty());
141 }
142
143 #[test]
145 fn v1_json_backwards_compat() {
146 let v1_json = r#"{
147 "id": "old-workout",
148 "version": "1.0.0",
149 "sport": "strength",
150 "date": "2026-01-01",
151 "root": {
152 "id": "sess-1",
153 "kind": { "type": "Session" },
154 "name": "Legacy Workout",
155 "children": [],
156 "payload": { "type": "Temporal" }
157 }
158 }"#;
159 let w: Workout = serde_json::from_str(v1_json).unwrap();
160 assert_eq!(w.id, "old-workout");
161 assert_eq!(w.version, "1.0.0");
162 assert!(w.streams.is_none());
163 assert!(w.laps.is_empty());
164 }
165
166 #[test]
168 fn workout_with_streams_and_laps_round_trip() {
169 use crate::stream::*;
170 use crate::lap::*;
171
172 let mut w = make_simple_workout();
173 w.streams = Some(Streams {
174 timestamps: vec![0.0, 5.0, 10.0],
175 channels: vec![
176 Stream {
177 metric: StreamMetric::HeartRate,
178 data: StreamData::Scalar(vec![85.0, 120.0, 135.0]),
179 },
180 ],
181 });
182
183 let mut summary = BTreeMap::new();
184 summary.insert(summary_keys::AVG_HR.into(), 113.0);
185 w.laps = vec![Lap {
186 start_index: Some(0),
187 end_index: Some(2),
188 summary,
189 trigger: LapTrigger::Auto,
190 name: Some("Full session".into()),
191 }];
192
193 let json = serde_json::to_string_pretty(&w).unwrap();
194 let back: Workout = serde_json::from_str(&json).unwrap();
195 assert!(back.streams.is_some());
196 assert_eq!(back.laps.len(), 1);
197 assert_eq!(back.laps[0].start_index, Some(0));
198 assert_eq!(back.laps[0].name, Some("Full session".into()));
199 }
200}