Skip to main content

syntax_workout_core/
workout.rs

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    /// Time-series sensor data (HR, GPS, pace, power, etc.).
21    /// Optional — strength-only workouts have no streams.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub streams: Option<Streams>,
24
25    /// Laps / splits / intervals with summary statistics.
26    /// Can exist with or without streams — summary-only laps are valid.
27    #[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    /// T12: v1.0 JSON without streams/laps fields must still deserialize.
144    #[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    /// T11: Full workout with streams and laps round-trips correctly.
167    #[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}