use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use ts_rs::TS;
use crate::lap::Lap;
use crate::node::Node;
use crate::stream::Streams;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Workout {
pub id: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sport: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
pub root: Node,
#[serde(skip_serializing_if = "Option::is_none")]
pub streams: Option<Streams>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub laps: Vec<Lap>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl Workout {
pub const SCHEMA_VERSION: &str = "1.1.0";
pub fn new(id: impl Into<String>, root: Node) -> Self {
Self {
id: id.into(),
version: Self::SCHEMA_VERSION.into(),
sport: None,
date: None,
root,
streams: None,
laps: vec![],
metadata: BTreeMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::execution_mode::ExecutionMode;
use crate::measure::{Measure, WeightUnit};
use crate::node::*;
fn make_simple_workout() -> Workout {
let set = Node {
id: NodeId::from_string("s1"),
kind: NodeKind::Set,
name: None,
children: vec![],
payload: NodePayload::Leaf {
measures: vec![
Measure::Weight { amount: 60.0, unit: WeightUnit::Kg },
Measure::Reps(10),
],
intensity: None,
},
metadata: BTreeMap::new(),
};
let exercise = Node {
id: NodeId::from_string("e1"),
kind: NodeKind::Exercise,
name: Some("Squat".into()),
children: vec![set],
payload: NodePayload::Exercise {
measures: vec![],
intensity: None,
rest_seconds: Some(120.0),
},
metadata: BTreeMap::new(),
};
let block = Node {
id: NodeId::from_string("b1"),
kind: NodeKind::Block,
name: None,
children: vec![exercise],
payload: NodePayload::Block {
execution_mode: ExecutionMode::Sequential,
rest_seconds: None,
},
metadata: BTreeMap::new(),
};
let session = Node {
id: NodeId::from_string("sess1"),
kind: NodeKind::Session,
name: Some("Leg Day".into()),
children: vec![block],
payload: NodePayload::Temporal { rest_seconds: None },
metadata: BTreeMap::new(),
};
let mut w = Workout::new("w1", session);
w.sport = Some("strength".into());
w.date = Some("2026-03-29".into());
w
}
#[test]
fn workout_round_trip() {
let w = make_simple_workout();
let json = serde_json::to_string_pretty(&w).unwrap();
let back: Workout = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "w1");
assert_eq!(back.version, "1.1.0");
assert_eq!(back.sport, Some("strength".into()));
assert_eq!(back.root.name, Some("Leg Day".into()));
assert!(back.streams.is_none());
assert!(back.laps.is_empty());
}
#[test]
fn workout_default_version() {
let node = Node {
id: NodeId::from_string("r"),
kind: NodeKind::Session,
name: None,
children: vec![],
payload: NodePayload::Temporal { rest_seconds: None },
metadata: BTreeMap::new(),
};
let w = Workout::new("test", node);
assert_eq!(w.version, "1.1.0");
assert!(w.streams.is_none());
assert!(w.laps.is_empty());
}
#[test]
fn v1_json_backwards_compat() {
let v1_json = r#"{
"id": "old-workout",
"version": "1.0.0",
"sport": "strength",
"date": "2026-01-01",
"root": {
"id": "sess-1",
"kind": { "type": "Session" },
"name": "Legacy Workout",
"children": [],
"payload": { "type": "Temporal" }
}
}"#;
let w: Workout = serde_json::from_str(v1_json).unwrap();
assert_eq!(w.id, "old-workout");
assert_eq!(w.version, "1.0.0");
assert!(w.streams.is_none());
assert!(w.laps.is_empty());
}
#[test]
fn workout_with_streams_and_laps_round_trip() {
use crate::stream::*;
use crate::lap::*;
let mut w = make_simple_workout();
w.streams = Some(Streams {
timestamps: vec![0.0, 5.0, 10.0],
channels: vec![
Stream {
metric: StreamMetric::HeartRate,
data: StreamData::Scalar(vec![85.0, 120.0, 135.0]),
},
],
});
let mut summary = BTreeMap::new();
summary.insert(summary_keys::AVG_HR.into(), 113.0);
w.laps = vec![Lap {
start_index: Some(0),
end_index: Some(2),
summary,
trigger: LapTrigger::Auto,
name: Some("Full session".into()),
}];
let json = serde_json::to_string_pretty(&w).unwrap();
let back: Workout = serde_json::from_str(&json).unwrap();
assert!(back.streams.is_some());
assert_eq!(back.laps.len(), 1);
assert_eq!(back.laps[0].start_index, Some(0));
assert_eq!(back.laps[0].name, Some("Full session".into()));
}
}