syntax-workout-core 0.2.0

Workout tree algebra — represent any physical workout as a recursive tree
Documentation
use serde::{Deserialize, Serialize};
use ts_rs::TS;

/// A GPS position with optional altitude.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Position {
    pub lat: f64,
    pub lng: f64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub altitude: Option<f64>,
}

/// Primary sensor metric for a stream channel.
///
/// Covers the universal metrics found across Garmin FIT, Strava, and HealthKit.
/// Use `Custom(String)` for device-specific or emerging metrics (e.g., VO2, HRV).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub enum StreamMetric {
    HeartRate,
    Speed,
    Cadence,
    Power,
    Altitude,
    Grade,
    Temperature,
    Position,
    Custom(String),
}

/// The data payload for a stream channel.
///
/// Per-channel typed enum: each channel is either entirely scalar values
/// or entirely GPS positions. Match once per channel, then work with
/// a uniform array.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
#[serde(tag = "type", content = "values")]
pub enum StreamData {
    /// Scalar measurements (HR in bpm, speed in m/s, power in watts, etc.).
    /// NaN represents missing values at a given index.
    Scalar(Vec<f64>),
    /// GPS positions. Individual entries may be None (GPS signal loss).
    Position(Vec<Option<Position>>),
}

impl StreamData {
    /// Returns the number of samples in this channel.
    pub fn len(&self) -> usize {
        match self {
            StreamData::Scalar(v) => v.len(),
            StreamData::Position(v) => v.len(),
        }
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

/// A named channel of time-series sensor data.
///
/// All channels within a [`Streams`] struct share the same timestamps
/// array — entry `data[i]` corresponds to `timestamps[i]`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Stream {
    pub metric: StreamMetric,
    pub data: StreamData,
}

/// Validation error for stream data.
#[derive(Debug, Clone, PartialEq)]
pub struct StreamValidationError {
    pub message: String,
}

impl std::fmt::Display for StreamValidationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl std::error::Error for StreamValidationError {}

/// Time-series data associated with a workout.
///
/// Design: Strava-style columnar layout. All arrays are index-aligned —
/// `timestamps[i]` and every `channels[j].data[i]` refer to the same
/// moment in time.
///
/// ```text
/// TIME AXIS ──────────────────────────────────────────────▶
///
/// timestamps: [0, 5, 10, 15, 20, ...]
/// channels:
///   HeartRate(Scalar): [85, 125, 130, 129, 128, ...]
///   Speed(Scalar):     [0, 2.5, 2.7, 2.8, 2.9, ...]
///   GPS(Position):     [{51.5,-0.1}, {51.5,-0.1}, ...]
/// ```
///
/// This is separate from the Node tree intentionally. The tree describes
/// structure (exercises, sets, blocks). Streams describe continuous
/// sensor recordings over time. They are connected by time offsets,
/// not by parent-child nesting.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Streams {
    /// Seconds from workout start for each sample index.
    /// Monotonically increasing. Length defines the shared array size.
    pub timestamps: Vec<f64>,

    /// Sensor data channels, all index-aligned with timestamps.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub channels: Vec<Stream>,
}

impl Streams {
    /// Validate structural invariants: all channel data arrays must have
    /// the same length as `timestamps`.
    ///
    /// Does NOT check semantic invariants (monotonic timestamps, valid
    /// lat/lng ranges, NaN values). Semantic validation is app-level.
    pub fn validate(&self) -> Result<(), StreamValidationError> {
        let expected = self.timestamps.len();
        for (i, channel) in self.channels.iter().enumerate() {
            let actual = channel.data.len();
            if actual != expected {
                return Err(StreamValidationError {
                    message: format!(
                        "channel {} ({:?}) has {} samples, expected {} (timestamps.len())",
                        i, channel.metric, actual, expected
                    ),
                });
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scalar_stream_round_trip() {
        let stream = Stream {
            metric: StreamMetric::HeartRate,
            data: StreamData::Scalar(vec![85.0, 125.0, 130.0, 129.0]),
        };
        let json = serde_json::to_string_pretty(&stream).unwrap();
        let back: Stream = serde_json::from_str(&json).unwrap();
        assert_eq!(back, stream);
        assert!(json.contains(r#""type": "Scalar""#));
    }

    #[test]
    fn position_stream_round_trip() {
        let stream = Stream {
            metric: StreamMetric::Position,
            data: StreamData::Position(vec![
                Some(Position { lat: 51.5074, lng: -0.1278, altitude: Some(45.0) }),
                None, // GPS signal loss
                Some(Position { lat: 51.5075, lng: -0.1277, altitude: None }),
            ]),
        };
        let json = serde_json::to_string_pretty(&stream).unwrap();
        let back: Stream = serde_json::from_str(&json).unwrap();
        assert_eq!(back, stream);
    }

    #[test]
    fn streams_full_round_trip() {
        let streams = Streams {
            timestamps: vec![0.0, 5.0, 10.0, 15.0],
            channels: vec![
                Stream {
                    metric: StreamMetric::HeartRate,
                    data: StreamData::Scalar(vec![85.0, 125.0, 130.0, 129.0]),
                },
                Stream {
                    metric: StreamMetric::Speed,
                    data: StreamData::Scalar(vec![0.0, 2.5, 2.7, 2.8]),
                },
                Stream {
                    metric: StreamMetric::Position,
                    data: StreamData::Position(vec![
                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(45.0) }),
                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(46.0) }),
                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(48.0) }),
                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(52.0) }),
                    ]),
                },
            ],
        };
        let json = serde_json::to_string_pretty(&streams).unwrap();
        let back: Streams = serde_json::from_str(&json).unwrap();
        assert_eq!(back, streams);
    }

    #[test]
    fn streams_validate_valid() {
        let streams = Streams {
            timestamps: vec![0.0, 5.0, 10.0],
            channels: vec![
                Stream {
                    metric: StreamMetric::HeartRate,
                    data: StreamData::Scalar(vec![85.0, 125.0, 130.0]),
                },
                Stream {
                    metric: StreamMetric::Position,
                    data: StreamData::Position(vec![
                        Some(Position { lat: 51.5, lng: -0.1, altitude: None }),
                        None,
                        Some(Position { lat: 51.5, lng: -0.1, altitude: None }),
                    ]),
                },
            ],
        };
        assert!(streams.validate().is_ok());
    }

    #[test]
    fn streams_validate_invalid_scalar() {
        let streams = Streams {
            timestamps: vec![0.0, 5.0, 10.0],
            channels: vec![
                Stream {
                    metric: StreamMetric::HeartRate,
                    data: StreamData::Scalar(vec![85.0, 125.0]), // wrong length
                },
            ],
        };
        let err = streams.validate().unwrap_err();
        assert!(err.message.contains("2 samples"));
        assert!(err.message.contains("expected 3"));
    }

    #[test]
    fn streams_validate_invalid_position() {
        let streams = Streams {
            timestamps: vec![0.0, 5.0, 10.0],
            channels: vec![
                Stream {
                    metric: StreamMetric::Position,
                    data: StreamData::Position(vec![
                        Some(Position { lat: 51.5, lng: -0.1, altitude: None }),
                        None,
                        None,
                        None, // extra entry
                    ]),
                },
            ],
        };
        let err = streams.validate().unwrap_err();
        assert!(err.message.contains("4 samples"));
        assert!(err.message.contains("expected 3"));
    }

    #[test]
    fn stream_metric_variants_round_trip() {
        let variants = vec![
            StreamMetric::HeartRate,
            StreamMetric::Speed,
            StreamMetric::Cadence,
            StreamMetric::Power,
            StreamMetric::Altitude,
            StreamMetric::Grade,
            StreamMetric::Temperature,
            StreamMetric::Position,
            StreamMetric::Custom("VO2".into()),
        ];
        for metric in variants {
            let json = serde_json::to_string(&metric).unwrap();
            let back: StreamMetric = serde_json::from_str(&json).unwrap();
            assert_eq!(back, metric);
        }
    }

    #[test]
    fn stream_data_len() {
        let scalar = StreamData::Scalar(vec![1.0, 2.0, 3.0]);
        assert_eq!(scalar.len(), 3);
        assert!(!scalar.is_empty());

        let pos = StreamData::Position(vec![None, None]);
        assert_eq!(pos.len(), 2);

        let empty = StreamData::Scalar(vec![]);
        assert!(empty.is_empty());
    }

    #[test]
    fn empty_streams_round_trip() {
        let streams = Streams {
            timestamps: vec![],
            channels: vec![],
        };
        let json = serde_json::to_string(&streams).unwrap();
        let back: Streams = serde_json::from_str(&json).unwrap();
        assert_eq!(back, streams);
        assert!(streams.validate().is_ok());
    }
}