Skip to main content

syntax_workout_core/
lap.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use ts_rs::TS;
4
5/// Well-known keys for [`Lap::summary`].
6///
7/// Use these constants to avoid typos when reading or writing summary stats.
8/// Custom metrics can use any string key.
9pub mod summary_keys {
10    pub const DISTANCE: &str = "distance";
11    pub const AVG_HR: &str = "avg_hr";
12    pub const MAX_HR: &str = "max_hr";
13    pub const AVG_SPEED: &str = "avg_speed";
14    pub const MAX_SPEED: &str = "max_speed";
15    pub const AVG_CADENCE: &str = "avg_cadence";
16    pub const AVG_POWER: &str = "avg_power";
17    pub const MAX_POWER: &str = "max_power";
18    pub const TOTAL_CALORIES: &str = "total_calories";
19    pub const ELEVATION_GAIN: &str = "elevation_gain";
20    pub const ELEVATION_LOSS: &str = "elevation_loss";
21    pub const DURATION: &str = "duration";
22    pub const STROKES: &str = "strokes";
23}
24
25/// How a lap boundary was triggered.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
27#[ts(export, export_to = "../../../bindings/napi/generated/")]
28pub enum LapTrigger {
29    /// Athlete pressed the lap button.
30    Manual,
31    /// Auto-lap every N meters.
32    Distance,
33    /// Auto-lap every N seconds.
34    Time,
35    /// Zone transition (e.g., heart rate zone change).
36    HeartRateZone,
37    /// Device-determined boundary.
38    Auto,
39    /// Custom or device-specific trigger.
40    Custom(String),
41}
42
43/// A lap / split / interval within a workout.
44///
45/// Laps reference into [`Streams::timestamps`] via `start_index`/`end_index`
46/// when streams are present. When streams are absent (summary-only laps),
47/// these indices are `None`.
48///
49/// Summary stats are pre-computed at write time. Keys follow a flat
50/// namespace — use [`summary_keys`] constants for well-known metrics.
51///
52/// ```text
53/// STREAMS:  [── timestamps ─────────────────────────────]
54/// LAPS:     [─ lap 1 ──][─ lap 2 ──][─ lap 3 ──][─ lap 4 ─]
55///            start=0      start=120   start=240   start=360
56///            end=119      end=239     end=359     end=479
57/// ```
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
59#[ts(export, export_to = "../../../bindings/napi/generated/")]
60pub struct Lap {
61    /// Index into streams.timestamps for the start of this lap.
62    /// None when streams are not available.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub start_index: Option<usize>,
65
66    /// Index into streams.timestamps for the end of this lap (inclusive).
67    /// None when streams are not available.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub end_index: Option<usize>,
70
71    /// Pre-computed summary statistics for this lap.
72    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73    pub summary: BTreeMap<String, f64>,
74
75    /// What triggered this lap boundary.
76    pub trigger: LapTrigger,
77
78    /// Optional display name (e.g., "Warmup", "Lap 3", "km 1").
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub name: Option<String>,
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn lap_round_trip() {
89        let mut summary = BTreeMap::new();
90        summary.insert(summary_keys::DISTANCE.into(), 1000.0);
91        summary.insert(summary_keys::AVG_HR.into(), 135.0);
92        summary.insert(summary_keys::DURATION.into(), 300.0);
93
94        let lap = Lap {
95            start_index: Some(0),
96            end_index: Some(119),
97            summary,
98            trigger: LapTrigger::Distance,
99            name: Some("km 1".into()),
100        };
101
102        let json = serde_json::to_string_pretty(&lap).unwrap();
103        let back: Lap = serde_json::from_str(&json).unwrap();
104        assert_eq!(back, lap);
105        assert!(json.contains(r#""distance""#));
106        assert!(json.contains("1000.0"));
107    }
108
109    #[test]
110    fn lap_without_indices() {
111        let mut summary = BTreeMap::new();
112        summary.insert(summary_keys::DISTANCE.into(), 5000.0);
113        summary.insert(summary_keys::DURATION.into(), 1650.0);
114
115        let lap = Lap {
116            start_index: None,
117            end_index: None,
118            summary,
119            trigger: LapTrigger::Auto,
120            name: Some("Full run".into()),
121        };
122
123        let json = serde_json::to_string_pretty(&lap).unwrap();
124        assert!(!json.contains("start_index"));
125        assert!(!json.contains("end_index"));
126        let back: Lap = serde_json::from_str(&json).unwrap();
127        assert_eq!(back, lap);
128    }
129
130    #[test]
131    fn lap_trigger_variants_round_trip() {
132        let variants = vec![
133            LapTrigger::Manual,
134            LapTrigger::Distance,
135            LapTrigger::Time,
136            LapTrigger::HeartRateZone,
137            LapTrigger::Auto,
138            LapTrigger::Custom("swim_length".into()),
139        ];
140        for trigger in variants {
141            let json = serde_json::to_string(&trigger).unwrap();
142            let back: LapTrigger = serde_json::from_str(&json).unwrap();
143            assert_eq!(back, trigger);
144        }
145    }
146
147    #[test]
148    fn lap_empty_summary_omitted() {
149        let lap = Lap {
150            start_index: Some(0),
151            end_index: Some(10),
152            summary: BTreeMap::new(),
153            trigger: LapTrigger::Manual,
154            name: None,
155        };
156        let json = serde_json::to_string(&lap).unwrap();
157        assert!(!json.contains("summary"));
158        assert!(!json.contains("name"));
159    }
160}