syntax-workout-core 0.2.0

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

/// Well-known keys for [`Lap::summary`].
///
/// Use these constants to avoid typos when reading or writing summary stats.
/// Custom metrics can use any string key.
pub mod summary_keys {
    pub const DISTANCE: &str = "distance";
    pub const AVG_HR: &str = "avg_hr";
    pub const MAX_HR: &str = "max_hr";
    pub const AVG_SPEED: &str = "avg_speed";
    pub const MAX_SPEED: &str = "max_speed";
    pub const AVG_CADENCE: &str = "avg_cadence";
    pub const AVG_POWER: &str = "avg_power";
    pub const MAX_POWER: &str = "max_power";
    pub const TOTAL_CALORIES: &str = "total_calories";
    pub const ELEVATION_GAIN: &str = "elevation_gain";
    pub const ELEVATION_LOSS: &str = "elevation_loss";
    pub const DURATION: &str = "duration";
    pub const STROKES: &str = "strokes";
}

/// How a lap boundary was triggered.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub enum LapTrigger {
    /// Athlete pressed the lap button.
    Manual,
    /// Auto-lap every N meters.
    Distance,
    /// Auto-lap every N seconds.
    Time,
    /// Zone transition (e.g., heart rate zone change).
    HeartRateZone,
    /// Device-determined boundary.
    Auto,
    /// Custom or device-specific trigger.
    Custom(String),
}

/// A lap / split / interval within a workout.
///
/// Laps reference into [`Streams::timestamps`] via `start_index`/`end_index`
/// when streams are present. When streams are absent (summary-only laps),
/// these indices are `None`.
///
/// Summary stats are pre-computed at write time. Keys follow a flat
/// namespace — use [`summary_keys`] constants for well-known metrics.
///
/// ```text
/// STREAMS:  [── timestamps ─────────────────────────────]
/// LAPS:     [─ lap 1 ──][─ lap 2 ──][─ lap 3 ──][─ lap 4 ─]
///            start=0      start=120   start=240   start=360
///            end=119      end=239     end=359     end=479
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Lap {
    /// Index into streams.timestamps for the start of this lap.
    /// None when streams are not available.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_index: Option<usize>,

    /// Index into streams.timestamps for the end of this lap (inclusive).
    /// None when streams are not available.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_index: Option<usize>,

    /// Pre-computed summary statistics for this lap.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub summary: BTreeMap<String, f64>,

    /// What triggered this lap boundary.
    pub trigger: LapTrigger,

    /// Optional display name (e.g., "Warmup", "Lap 3", "km 1").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

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

    #[test]
    fn lap_round_trip() {
        let mut summary = BTreeMap::new();
        summary.insert(summary_keys::DISTANCE.into(), 1000.0);
        summary.insert(summary_keys::AVG_HR.into(), 135.0);
        summary.insert(summary_keys::DURATION.into(), 300.0);

        let lap = Lap {
            start_index: Some(0),
            end_index: Some(119),
            summary,
            trigger: LapTrigger::Distance,
            name: Some("km 1".into()),
        };

        let json = serde_json::to_string_pretty(&lap).unwrap();
        let back: Lap = serde_json::from_str(&json).unwrap();
        assert_eq!(back, lap);
        assert!(json.contains(r#""distance""#));
        assert!(json.contains("1000.0"));
    }

    #[test]
    fn lap_without_indices() {
        let mut summary = BTreeMap::new();
        summary.insert(summary_keys::DISTANCE.into(), 5000.0);
        summary.insert(summary_keys::DURATION.into(), 1650.0);

        let lap = Lap {
            start_index: None,
            end_index: None,
            summary,
            trigger: LapTrigger::Auto,
            name: Some("Full run".into()),
        };

        let json = serde_json::to_string_pretty(&lap).unwrap();
        assert!(!json.contains("start_index"));
        assert!(!json.contains("end_index"));
        let back: Lap = serde_json::from_str(&json).unwrap();
        assert_eq!(back, lap);
    }

    #[test]
    fn lap_trigger_variants_round_trip() {
        let variants = vec![
            LapTrigger::Manual,
            LapTrigger::Distance,
            LapTrigger::Time,
            LapTrigger::HeartRateZone,
            LapTrigger::Auto,
            LapTrigger::Custom("swim_length".into()),
        ];
        for trigger in variants {
            let json = serde_json::to_string(&trigger).unwrap();
            let back: LapTrigger = serde_json::from_str(&json).unwrap();
            assert_eq!(back, trigger);
        }
    }

    #[test]
    fn lap_empty_summary_omitted() {
        let lap = Lap {
            start_index: Some(0),
            end_index: Some(10),
            summary: BTreeMap::new(),
            trigger: LapTrigger::Manual,
            name: None,
        };
        let json = serde_json::to_string(&lap).unwrap();
        assert!(!json.contains("summary"));
        assert!(!json.contains("name"));
    }
}