From a08b3ed76c097fe76e14db11ea913be1d7e27039 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 13:02:03 +0300 Subject: [PATCH] feat: workout model, JSON parser and flatten (phase 1) --- src-tauri/src/lib.rs | 1 + src-tauri/src/workout.rs | 283 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 src-tauri/src/workout.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c76843..f9c27b2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod device_repo; mod state; mod telemetry; mod commands; +mod workout; use std::pin::Pin; use std::sync::{Arc, Mutex, RwLock}; diff --git a/src-tauri/src/workout.rs b/src-tauri/src/workout.rs new file mode 100644 index 0000000..22085ae --- /dev/null +++ b/src-tauri/src/workout.rs @@ -0,0 +1,283 @@ +//! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание +//! вложенных повторов в линейный список сегментов для исполнения на станке. + +// TODO(phase 2): убрать, когда модуль будет подключён к tauri-командам. +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; + +// ===== Чистые (доменные) типы ===== + +/// Целевая мощность шага. Значения — в % от FTP (все скачанные воркауты +/// используют `units: "%ftp"`). Перевод %FTP -> ватты делается на этапе +/// исполнения, зная FTP. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PowerTarget { + /// Фиксированная мощность, % FTP. + Fixed { percent: f64 }, + /// Линейный ramp от `from` до `to` % FTP за время шага (`ramp: true`). + Ramp { from: f64, to: f64 }, + /// Свободный диапазон `low..high` % FTP (не ramp) — гонщик едет внутри. + Range { low: f64, high: f64 }, +} + +/// Один шаг воркаута до разворачивания повторов. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WorkoutStep { + pub text: Option, + pub duration_secs: u32, + pub power: Option, + pub cadence_rpm: Option, + /// Сколько раз повторить (для блока с `children`); для листа — 1. + pub repeats: u32, + pub children: Vec, +} + +/// Развёрнутый линейный сегмент — атом исполнения. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Segment { + pub text: Option, + pub duration_secs: u32, + pub power: Option, + pub cadence_rpm: Option, +} + +/// Воркаут целиком. +#[derive(Debug, Clone, Serialize)] +pub struct Workout { + pub name: String, + pub start_date_local: Option, + pub icu_training_load: Option, + pub steps: Vec, +} + +// ===== Сырые serde-структуры (повторяют формат /events intervals.icu) ===== + +#[derive(Deserialize)] +struct RawEvent { + name: Option, + #[serde(rename = "start_date_local")] + start_date_local: Option, + #[serde(rename = "icu_training_load")] + icu_training_load: Option, + workout_doc: Option, +} + +#[derive(Deserialize)] +struct RawWorkoutDoc { + #[serde(default)] + steps: Vec, +} + +#[derive(Deserialize)] +struct RawStep { + #[serde(default)] + text: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + ramp: bool, + #[serde(default)] + reps: Option, + #[serde(default)] + power: Option, + #[serde(default)] + cadence: Option, + #[serde(default)] + steps: Vec, +} + +#[derive(Deserialize)] +struct RawPower { + #[serde(default)] + value: Option, + #[serde(default)] + start: Option, + #[serde(default)] + end: Option, +} + +#[derive(Deserialize)] +struct RawCadence { + #[serde(default)] + value: Option, +} + +// ===== Ошибки ===== + +#[derive(Debug, thiserror::Error)] +pub enum WorkoutError { + #[error("invalid workout JSON: {0}")] + Json(#[from] serde_json::Error), +} + +// ===== Парсинг ===== + +/// Разбирает event-JSON intervals.icu в `Workout`. +pub fn parse_workout(json: &str) -> Result { + let raw: RawEvent = serde_json::from_str(json)?; + let steps = raw + .workout_doc + .map(|d| d.steps) + .unwrap_or_default() + .into_iter() + .map(convert_step) + .collect(); + Ok(Workout { + name: raw.name.unwrap_or_default(), + start_date_local: raw.start_date_local, + icu_training_load: raw.icu_training_load, + steps, + }) +} + +fn convert_step(raw: RawStep) -> WorkoutStep { + WorkoutStep { + text: raw.text, + duration_secs: raw.duration.unwrap_or(0), + power: raw.power.and_then(|p| convert_power(p, raw.ramp)), + cadence_rpm: raw.cadence.and_then(|c| c.value.map(|v| v.round() as u16)), + repeats: raw.reps.unwrap_or(1), + children: raw.steps.into_iter().map(convert_step).collect(), + } +} + +/// Переводит сырую мощность в `PowerTarget`. Флаг `ramp` (с шага) отличает +/// линейный ramp от свободного диапазона. Предполагаем `units == "%ftp"` +/// (все скачанные воркауты используют %ftp); абсолютные ватты не поддерживаются. +fn convert_power(p: RawPower, ramp: bool) -> Option { + if let Some(v) = p.value { + return Some(PowerTarget::Fixed { percent: v }); + } + match (p.start, p.end) { + (Some(s), Some(e)) if ramp => Some(PowerTarget::Ramp { from: s, to: e }), + (Some(s), Some(e)) => Some(PowerTarget::Range { low: s, high: e }), + _ => None, + } +} + +// ===== Flatten ===== + +/// Разворачивает шаги (включая вложенные `repeats`) в линейный список сегментов. +pub fn flatten(steps: &[WorkoutStep]) -> Vec { + let mut out = Vec::new(); + for step in steps { + let reps = step.repeats.max(1) as usize; + for _ in 0..reps { + if step.children.is_empty() { + out.push(Segment { + text: step.text.clone(), + duration_secs: step.duration_secs, + power: step.power.clone(), + cadence_rpm: step.cadence_rpm, + }); + } else { + out.extend(flatten(&step.children)); + } + } + } + out +} + +/// Суммарная длительность линейного списка сегментов, секунды. +pub fn total_duration_secs(segments: &[Segment]) -> u64 { + segments.iter().map(|s| s.duration_secs as u64).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + const EVENT: &str = r#"{ + "name": "Test", + "start_date_local": "2026-10-28T09:00:00", + "icu_training_load": 91, + "workout_doc": { + "steps": [ + {"ramp": true, "text": "Warmup", "power": {"units":"%ftp","start":45,"end":75}, "cadence": {"units":"rpm","value":85}, "duration": 600}, + {"text": "Endurance", "power": {"units":"%ftp","start":65,"end":75}, "duration": 300}, + {"text": "Primer", "power": {"units":"%ftp","value":95}, "cadence": {"units":"rpm","value":90}, "duration": 60}, + {"reps": 4, "text": "Main", "steps": [ + {"text": "Under", "power": {"units":"%ftp","value":90}, "duration": 180}, + {"text": "Over", "power": {"units":"%ftp","value":105}, "duration": 120} + ], "duration": 1200} + ] + } + }"#; + + #[test] + fn parses_workout_metadata() { + let w = parse_workout(EVENT).unwrap(); + assert_eq!(w.name, "Test"); + assert_eq!(w.start_date_local.as_deref(), Some("2026-10-28T09:00:00")); + assert_eq!(w.icu_training_load, Some(91.0)); + assert_eq!(w.steps.len(), 4); + } + + #[test] + fn parses_power_targets() { + let w = parse_workout(EVENT).unwrap(); + // Warmup -> ramp + assert_eq!( + w.steps[0].power, + Some(PowerTarget::Ramp { from: 45.0, to: 75.0 }) + ); + // Endurance (start/end, no ramp flag) -> range + assert_eq!( + w.steps[1].power, + Some(PowerTarget::Range { low: 65.0, high: 75.0 }) + ); + // Primer -> fixed + assert_eq!( + w.steps[2].power, + Some(PowerTarget::Fixed { percent: 95.0 }) + ); + // cadence: Warmup 85rpm + assert_eq!(w.steps[0].cadence_rpm, Some(85)); + } + + #[test] + fn flatten_expands_repeats() { + let w = parse_workout(EVENT).unwrap(); + let segs = flatten(&w.steps); + // Warmup + Endurance + Primer + 4x(Under,Over) = 3 + 8 = 11 + assert_eq!(segs.len(), 11); + // первая четвёрка после первых трёх шагов — Under/Over/Under/Over + let main = &segs[3..11]; + assert_eq!(main.len(), 8); + assert_eq!(main[0].text.as_deref(), Some("Under")); + assert_eq!(main[1].text.as_deref(), Some("Over")); + assert_eq!(main[6].text.as_deref(), Some("Under")); + assert_eq!(main[7].text.as_deref(), Some("Over")); + } + + #[test] + fn total_duration_matches() { + let w = parse_workout(EVENT).unwrap(); + let segs = flatten(&w.steps); + // 600 + 300 + 60 + 4*(180+120) = 2160 + assert_eq!(total_duration_secs(&segs), 2160); + } + + #[test] + #[ignore] + fn parses_real_downloaded_workouts() { + let cases = [ + ("2026-10-28_threshold-over-under-2x20.json", 4380), + ("2026-10-30_vo2max-5x4min.json", 3600), + ("2026-11-01_long-z2-endurance.json", 7200), + ]; + for (file, expected_secs) in cases { + let path = format!("/home/dude/code/ridein/workouts/{file}"); + let json = std::fs::read_to_string(&path).unwrap(); + let w = parse_workout(&json).unwrap(); + let segs = flatten(&w.steps); + assert_eq!( + total_duration_secs(&segs), + expected_secs, + "workout {file}: flat total mismatch" + ); + } + } +}