From a08b3ed76c097fe76e14db11ea913be1d7e27039 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 13:02:03 +0300 Subject: [PATCH 01/14] 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" + ); + } + } +} -- 2.36.5 From 46e6510f05e8dfbda168b16a71b73dd4b5c0f52b Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 13:49:56 +0300 Subject: [PATCH 02/14] feat: list_workouts and get_workout commands (phase 2) --- src-tauri/src/lib.rs | 16 +++++- src-tauri/src/workout.rs | 115 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9c27b2..8b5b47c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -217,6 +217,18 @@ fn trainer_device(state: &State) -> Result { .ok_or_else(|| "станок не найден".to_string()) } +/// Список доступных Ride-воркаутов (сводки). +#[tauri::command] +fn list_workouts() -> Result, String> { + workout::list_workouts(&workout::workouts_dir()).map_err(|e| e.to_string()) +} + +/// Полный воркаут по имени файла (из каталога воркаутов). +#[tauri::command] +fn get_workout(path: String) -> Result { + workout::read_workout(&path).map_err(|e| e.to_string()) +} + /// Why the watchdog loop stopped consuming a stream. enum StreamOutcome { /// The stream ended (peripheral disconnected / notifications closed). @@ -384,7 +396,9 @@ pub fn run() { set_target_power, set_target_cadence, trainer_start, - trainer_stop + trainer_stop, + list_workouts, + get_workout ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/workout.rs b/src-tauri/src/workout.rs index 22085ae..2055f62 100644 --- a/src-tauri/src/workout.rs +++ b/src-tauri/src/workout.rs @@ -1,8 +1,7 @@ //! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание //! вложенных повторов в линейный список сегментов для исполнения на станке. -// TODO(phase 2): убрать, когда модуль будет подключён к tauri-командам. -#![allow(dead_code)] +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -52,6 +51,16 @@ pub struct Workout { pub steps: Vec, } +/// Сводка воркаута для списка. +#[derive(Debug, Clone, Serialize)] +pub struct WorkoutSummary { + pub name: String, + pub date: String, + pub duration_min: u64, + pub load: Option, + pub path: String, +} + // ===== Сырые serde-структуры (повторяют формат /events intervals.icu) ===== #[derive(Deserialize)] @@ -110,6 +119,10 @@ struct RawCadence { pub enum WorkoutError { #[error("invalid workout JSON: {0}")] Json(#[from] serde_json::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("unsafe workout path: {0}")] + InvalidPath(String), } // ===== Парсинг ===== @@ -185,6 +198,64 @@ pub fn total_duration_secs(segments: &[Segment]) -> u64 { segments.iter().map(|s| s.duration_secs as u64).sum() } +/// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd. +pub fn workouts_dir() -> PathBuf { + std::env::var("RIDEIN_WORKOUTS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("workouts")) +} + +/// Сканирует каталог и возвращает сводки Ride-воркаутов (с непустыми шагами), +/// отсортированные по дате. `events.json` и неразбираемые файлы пропускаются. +pub fn list_workouts(dir: &Path) -> Result, WorkoutError> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if path.file_name().and_then(|f| f.to_str()) == Some("events.json") { + continue; + } + let workout = match read_workout_file(&path) { + Ok(w) => w, + Err(e) => { + eprintln!("skip unparseable workout {}: {e}", path.display()); + continue; + } + }; + if workout.steps.is_empty() { + continue; // не Ride (нет workout_doc.steps) + } + let duration_min = total_duration_secs(&flatten(&workout.steps)) / 60; + out.push(WorkoutSummary { + name: workout.name.clone(), + date: workout.start_date_local.clone().unwrap_or_default(), + duration_min, + load: workout.icu_training_load, + path: path.file_name().unwrap().to_string_lossy().into_owned(), + }); + } + out.sort_by(|a, b| a.date.cmp(&b.date)); + Ok(out) +} + +/// Читает воркаут по имени файла (без пути) из каталога воркаутов. +pub fn read_workout(filename: &str) -> Result { + let fname = Path::new(filename); + if filename.contains("..") || fname.components().count() != 1 { + return Err(WorkoutError::InvalidPath(filename.to_string())); + } + let path = workouts_dir().join(fname); + read_workout_file(&path) +} + +fn read_workout_file(path: &Path) -> Result { + let json = std::fs::read_to_string(path)?; + parse_workout(&json) +} + #[cfg(test)] mod tests { use super::*; @@ -279,5 +350,45 @@ mod tests { "workout {file}: flat total mismatch" ); } + + // list_workouts на реальном каталоге: 3 Ride-воркаута, по дате. + let dir = std::path::Path::new("/home/dude/code/ridein/workouts"); + let list = list_workouts(dir).unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].name, "Threshold Over-Under 2x20"); + assert_eq!(list[1].name, "VO2max 5x4min"); + assert_eq!(list[2].name, "Long Z2 (Endurance)"); + } + + #[test] + fn lists_ride_workouts_skipping_non_ride() { + let dir = std::env::temp_dir().join("ridein-workout-list-test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Ride-воркаут (с шагами) + std::fs::write(dir.join("2026-10-28_test-a.json"), EVENT).unwrap(); + // events.json — пропускается по имени + std::fs::write(dir.join("events.json"), "[{}]").unwrap(); + // не-Ride (пустой workout_doc) — пропускается + std::fs::write( + dir.join("2026-10-30_yoga.json"), + r#"{"name":"Yoga","workout_doc":{"steps":[]}}"#, + ) + .unwrap(); + + let list = list_workouts(&dir).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Test"); + assert_eq!(list[0].duration_min, 36); // 2160s / 60 + assert_eq!(list[0].load, Some(91.0)); + assert_eq!(list[0].path, "2026-10-28_test-a.json"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn read_workout_rejects_traversal() { + assert!(read_workout("../etc/passwd").is_err()); + assert!(read_workout("a/b.json").is_err()); } } -- 2.36.5 From 981747fc50c427b6f3437198895e5253b28058a1 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 13:55:03 +0300 Subject: [PATCH 03/14] feat: FTP in state with set_ftp/get_ftp (phase 3) --- src-tauri/src/lib.rs | 21 ++++++++++++++++++++- src-tauri/src/state.rs | 5 +++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b5b47c..51f67e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -229,6 +229,22 @@ fn get_workout(path: String) -> Result { workout::read_workout(&path).map_err(|e| e.to_string()) } +/// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты. +#[tauri::command] +fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> { + if ftp == 0 { + return Err("FTP должен быть больше нуля".to_string()); + } + *app.state::().ftp.lock().unwrap() = ftp; + Ok(()) +} + +/// Текущий FTP гонщика (Вт). +#[tauri::command] +fn get_ftp(app: AppHandle) -> u16 { + *app.state::().ftp.lock().unwrap() +} + /// Why the watchdog loop stopped consuming a stream. enum StreamOutcome { /// The stream ended (peripheral disconnected / notifications closed). @@ -386,6 +402,7 @@ pub fn run() { trainer: Mutex::new(None), streams: Mutex::new(std::collections::HashMap::new()), trainer_state: Mutex::new(TrainerState::default()), + ftp: Mutex::new(state::DEFAULT_FTP), }); Ok::<(), Box>(()) }) @@ -398,7 +415,9 @@ pub fn run() { trainer_start, trainer_stop, list_workouts, - get_workout + get_workout, + set_ftp, + get_ftp ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7871228..02f098c 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -6,6 +6,9 @@ use tauri::async_runtime::JoinHandle; use crate::ble::SensorSource; use crate::device::Device; +/// FTP по умолчанию (Вт) — для перевода %FTP воркаутов в ватты. +pub const DEFAULT_FTP: u16 = 250; + /// Trainer control state, restored after a reconnect. #[derive(Default)] pub struct TrainerState { @@ -24,4 +27,6 @@ pub struct State { pub streams: Mutex>>, /// Trainer control state, restored after a reconnect. pub trainer_state: Mutex, + /// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты. + pub ftp: Mutex, } -- 2.36.5 From b3f42d1f33404bc10da1b92cb25f127a850b000f Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:05:12 +0300 Subject: [PATCH 04/14] feat: workout execution engine (phase 4) --- src-tauri/src/lib.rs | 185 ++++++++++++++++++++++++++++++++++++++- src-tauri/src/state.rs | 9 ++ src-tauri/src/workout.rs | 48 ++++++++++ 3 files changed, 240 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 51f67e7..55da7b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod commands; mod workout; use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; @@ -19,7 +20,7 @@ use crate::ble::{BleSource, MockSource, SensorSource}; use crate::commands::Command; use crate::device::Device; use crate::device_repo::{repo_from_devices, Repo}; -use crate::state::{State, TrainerState}; +use crate::state::{State, TrainerState, WorkoutHandle}; use crate::telemetry::Telemetry; use btleplug::api::Manager as ApiManager; use btleplug::platform::Manager as BleManager; @@ -53,6 +54,21 @@ struct ConnectionEvent { status: &'static str, // "reconnecting" | "reconnected" | "gave_up" } +#[derive(Clone, Serialize)] +struct WorkoutProgressEvent { + step_index: usize, + step_name: String, + target_watts: Option, + target_cadence: Option, + elapsed_secs: u64, + total_secs: u64, +} + +#[derive(Clone, Serialize)] +struct WorkoutFinishedEvent { + ok: bool, +} + #[tauri::command] async fn init(app: AppHandle) -> Result<(), String> { let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; @@ -245,6 +261,167 @@ fn get_ftp(app: AppHandle) -> u16 { *app.state::().ftp.lock().unwrap() } +/// Вычисляет и отправляет целевые мощность/каденс сегмента, если изменились. +async fn apply_target( + source: &Arc, + device: &Device, + seg: &workout::Segment, + elapsed_secs: f64, + ftp: u16, + last_watts: Option, + last_cadence: Option, +) -> (Option, Option) { + let watts = seg.power.as_ref().map(|p| { + workout::percent_to_watts(workout::target_percent(p, elapsed_secs, seg.duration_secs), ftp) + }); + let cadence = seg.cadence_rpm; + + if watts != last_watts { + if let Some(w) = watts { + let _ = source.command(device, Command::FtmsSetTargetPower(w)).await; + } + } + if cadence != last_cadence { + if let Some(rpm) = cadence { + if let Some(half) = rpm.checked_mul(2) { + let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; + } + } + } + (watts, cadence) +} + +/// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце. +async fn run_workout( + app: AppHandle, + source: Arc, + device: Device, + segments: Vec, + ftp: u16, + paused: Arc, +) { + if source.request_control(&device).await.is_err() { + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: false }); + return; + } + let _ = source.command(&device, Command::FtmsStart).await; + + let total = workout::total_duration_secs(&segments); + let mut elapsed_total: u64 = 0; + + for (idx, seg) in segments.iter().enumerate() { + let dur = seg.duration_secs as u64; + let mut last_watts: Option = None; + let mut last_cadence: Option = None; + let mut tick = time::interval(Duration::from_secs(1)); + tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay); + + // t = 0 + let (w, c) = apply_target(&source, &device, seg, 0.0, ftp, last_watts, last_cadence).await; + last_watts = w; + last_cadence = c; + let _ = app.emit( + "workout-progress", + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: w, + target_cadence: c, + elapsed_secs: elapsed_total, + total_secs: total, + }, + ); + + let mut elapsed_in_seg: u64 = 0; + while elapsed_in_seg < dur { + tick.tick().await; + if paused.load(Ordering::SeqCst) { + continue; + } + elapsed_in_seg += 1; + let (w, c) = + apply_target(&source, &device, seg, elapsed_in_seg as f64, ftp, last_watts, last_cadence) + .await; + last_watts = w; + last_cadence = c; + elapsed_total += 1; + let _ = app.emit( + "workout-progress", + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: w, + target_cadence: c, + elapsed_secs: elapsed_total, + total_secs: total, + }, + ); + } + } + + let _ = source.command(&device, Command::FtmsStop).await; + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true }); +} + +/// Запустить воркаут (по имени файла из каталога воркаутов). +#[tauri::command] +fn start_workout(app: AppHandle, path: String) -> Result<(), String> { + let workout = workout::read_workout(&path).map_err(|e| e.to_string())?; + let segments = workout::flatten(&workout.steps); + if segments.is_empty() { + return Err("воркаут пустой".to_string()); + } + + let state = app.state::(); + let ftp = *state.ftp.lock().unwrap(); + let device = trainer_device(&state)?; + let source = state.source.clone(); + + // остановить предыдущий воркаут, если был + if let Some(old) = state.workout.lock().unwrap().take() { + old.task.abort(); + } + + let paused = Arc::new(AtomicBool::new(false)); + let app_t = app.clone(); + let source_t = source.clone(); + let device_t = device.clone(); + let paused_t = paused.clone(); + let handle = async_runtime::spawn(async move { + run_workout(app_t, source_t, device_t, segments, ftp, paused_t).await; + }); + + *state.workout.lock().unwrap() = Some(WorkoutHandle { task: handle, paused }); + Ok(()) +} + +/// Остановить текущий воркаут. +#[tauri::command] +async fn stop_workout(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + // взять ручку (guard отпускается сразу, до await) + let handle = { + let mut guard = state.workout.lock().unwrap(); + guard.take() + }; + if let Some(h) = handle { + h.task.abort(); + let device = trainer_device(&state)?; + let _ = state.source.command(&device, Command::FtmsStop).await; + } + Ok(()) +} + +/// Пауза / возобновление текущего воркаута. +#[tauri::command] +fn pause_workout(app: AppHandle, paused: bool) -> Result<(), String> { + let state = app.state::(); + let guard = state.workout.lock().unwrap(); + let handle = guard.as_ref().ok_or("воркаут не запущен")?; + handle.paused.store(paused, Ordering::SeqCst); + Ok(()) +} + /// Why the watchdog loop stopped consuming a stream. enum StreamOutcome { /// The stream ended (peripheral disconnected / notifications closed). @@ -403,6 +580,7 @@ pub fn run() { streams: Mutex::new(std::collections::HashMap::new()), trainer_state: Mutex::new(TrainerState::default()), ftp: Mutex::new(state::DEFAULT_FTP), + workout: Mutex::new(None), }); Ok::<(), Box>(()) }) @@ -417,7 +595,10 @@ pub fn run() { list_workouts, get_workout, set_ftp, - get_ftp + get_ftp, + start_workout, + stop_workout, + pause_workout ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 02f098c..e7a6fcf 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex, RwLock}; use tauri::async_runtime::JoinHandle; @@ -17,6 +18,12 @@ pub struct TrainerState { pub running: bool, } +/// Ручка запущенного воркаута: задача + флаг паузы. +pub struct WorkoutHandle { + pub task: JoinHandle<()>, + pub paused: Arc, +} + pub struct State { pub source: Arc, /// Все найденные при скане устройства (для поиска по id при подключении). @@ -29,4 +36,6 @@ pub struct State { pub trainer_state: Mutex, /// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты. pub ftp: Mutex, + /// Запущенный воркаут (если идёт). + pub workout: Mutex>, } diff --git a/src-tauri/src/workout.rs b/src-tauri/src/workout.rs index 2055f62..2549362 100644 --- a/src-tauri/src/workout.rs +++ b/src-tauri/src/workout.rs @@ -198,6 +198,28 @@ pub fn total_duration_secs(segments: &[Segment]) -> u64 { segments.iter().map(|s| s.duration_secs as u64).sum() } +/// Целевая мощность (%FTP) в момент `elapsed_secs` внутри шага. +/// Fixed — константа; Ramp — линейная интерполяция from→to; Range — середина. +pub fn target_percent(power: &PowerTarget, elapsed_secs: f64, duration_secs: u32) -> f64 { + match power { + PowerTarget::Fixed { percent } => *percent, + PowerTarget::Ramp { from, to } => { + if duration_secs == 0 { + *to + } else { + let t = (elapsed_secs / duration_secs as f64).clamp(0.0, 1.0); + from + (to - from) * t + } + } + PowerTarget::Range { low, high } => (low + high) / 2.0, + } +} + +/// %FTP → ватты (округление до целого). +pub fn percent_to_watts(percent: f64, ftp: u16) -> i16 { + (percent / 100.0 * ftp as f64).round() as i16 +} + /// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd. pub fn workouts_dir() -> PathBuf { std::env::var("RIDEIN_WORKOUTS_DIR") @@ -391,4 +413,30 @@ mod tests { assert!(read_workout("../etc/passwd").is_err()); assert!(read_workout("a/b.json").is_err()); } + + #[test] + fn percent_to_watts_rounds() { + assert_eq!(percent_to_watts(100.0, 250), 250); + assert_eq!(percent_to_watts(90.0, 250), 225); + assert_eq!(percent_to_watts(105.0, 250), 263); // 262.5 → 263 + assert_eq!(percent_to_watts(55.0, 250), 138); // 137.5 → 138 + } + + #[test] + fn ramp_interpolates_linearly() { + let ramp = PowerTarget::Ramp { from: 45.0, to: 75.0 }; + assert_eq!(target_percent(&ramp, 0.0, 600), 45.0); + assert_eq!(target_percent(&ramp, 300.0, 600), 60.0); + assert_eq!(target_percent(&ramp, 600.0, 600), 75.0); + } + + #[test] + fn fixed_and_range_targets() { + let fixed = PowerTarget::Fixed { percent: 95.0 }; + assert_eq!(target_percent(&fixed, 0.0, 60), 95.0); + assert_eq!(target_percent(&fixed, 30.0, 60), 95.0); + + let range = PowerTarget::Range { low: 65.0, high: 75.0 }; + assert_eq!(target_percent(&range, 0.0, 300), 70.0); + } } -- 2.36.5 From 688de2926992e9cec81df5baeb5e68f51f9e2204 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:14:53 +0300 Subject: [PATCH 05/14] refactor: flatten nested ifs in workout engine --- src-tauri/src/lib.rs | 85 ++++++++++++++++++++------------------------ 1 file changed, 38 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55da7b3..208ce38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -261,34 +261,44 @@ fn get_ftp(app: AppHandle) -> u16 { *app.state::().ftp.lock().unwrap() } -/// Вычисляет и отправляет целевые мощность/каденс сегмента, если изменились. -async fn apply_target( +/// Вычисляет и отправляет целевую мощность сегмента, если она изменилась. +async fn apply_power( source: &Arc, device: &Device, seg: &workout::Segment, elapsed_secs: f64, ftp: u16, last_watts: Option, - last_cadence: Option, -) -> (Option, Option) { +) -> Option { let watts = seg.power.as_ref().map(|p| { workout::percent_to_watts(workout::target_percent(p, elapsed_secs, seg.duration_secs), ftp) }); - let cadence = seg.cadence_rpm; - - if watts != last_watts { - if let Some(w) = watts { - let _ = source.command(device, Command::FtmsSetTargetPower(w)).await; - } - } - if cadence != last_cadence { - if let Some(rpm) = cadence { - if let Some(half) = rpm.checked_mul(2) { - let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; - } - } + if let Some(w) = watts.filter(|w| Some(*w) != last_watts) { + let _ = source.command(device, Command::FtmsSetTargetPower(w)).await; } - (watts, cadence) + watts +} + +/// Шлёт событие прогресса воркаута на фронт. +fn emit_progress( + app: &AppHandle, + idx: usize, + seg: &workout::Segment, + watts: Option, + elapsed_total: u64, + total: u64, +) { + let _ = app.emit( + "workout-progress", + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: watts, + target_cadence: seg.cadence_rpm, + elapsed_secs: elapsed_total, + total_secs: total, + }, + ); } /// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце. @@ -311,26 +321,20 @@ async fn run_workout( for (idx, seg) in segments.iter().enumerate() { let dur = seg.duration_secs as u64; + + // каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS). + if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) { + let _ = source.command(&device, Command::FtmsSetTargetCadence(half)).await; + } + let mut last_watts: Option = None; - let mut last_cadence: Option = None; let mut tick = time::interval(Duration::from_secs(1)); tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay); // t = 0 - let (w, c) = apply_target(&source, &device, seg, 0.0, ftp, last_watts, last_cadence).await; + let w = apply_power(&source, &device, seg, 0.0, ftp, last_watts).await; last_watts = w; - last_cadence = c; - let _ = app.emit( - "workout-progress", - WorkoutProgressEvent { - step_index: idx, - step_name: seg.text.clone().unwrap_or_default(), - target_watts: w, - target_cadence: c, - elapsed_secs: elapsed_total, - total_secs: total, - }, - ); + emit_progress(&app, idx, seg, w, elapsed_total, total); let mut elapsed_in_seg: u64 = 0; while elapsed_in_seg < dur { @@ -339,23 +343,10 @@ async fn run_workout( continue; } elapsed_in_seg += 1; - let (w, c) = - apply_target(&source, &device, seg, elapsed_in_seg as f64, ftp, last_watts, last_cadence) - .await; + let w = apply_power(&source, &device, seg, elapsed_in_seg as f64, ftp, last_watts).await; last_watts = w; - last_cadence = c; elapsed_total += 1; - let _ = app.emit( - "workout-progress", - WorkoutProgressEvent { - step_index: idx, - step_name: seg.text.clone().unwrap_or_default(), - target_watts: w, - target_cadence: c, - elapsed_secs: elapsed_total, - total_secs: total, - }, - ); + emit_progress(&app, idx, seg, w, elapsed_total, total); } } -- 2.36.5 From 680d80fdedf3e24bf57fd8172681d25ea3eebeac Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:19:37 +0300 Subject: [PATCH 06/14] feat: workout selection and progress UI (phase 5) --- src/index.html | 38 +++++++++---- src/main.js | 142 +++++++++++++++++++++++++++++++++---------------- src/styles.css | 67 +++++++++++++++++++++++ 3 files changed, 191 insertions(+), 56 deletions(-) diff --git a/src/index.html b/src/index.html index 60329cb..aa0a9d7 100644 --- a/src/index.html +++ b/src/index.html @@ -38,9 +38,21 @@
00:00
- +
—
+
+ + +

+
+
мощность @@ -59,16 +71,20 @@
-
- - - +
+
+ — + +
+
+
+
+
+ +
+ + +
diff --git a/src/main.js b/src/main.js index 7eb5121..885d932 100644 --- a/src/main.js +++ b/src/main.js @@ -122,9 +122,10 @@ async function connect(deviceId, btn, category, card) { } // --- Переход на экран упражнения --- -startBtn.addEventListener("click", () => { +startBtn.addEventListener("click", async () => { connectScreen.hidden = true; workoutScreen.hidden = false; + await loadWorkouts(); }); // --- Телеметрия --- @@ -182,62 +183,113 @@ listen("connection", (event) => { applyStatus(sel, category, status); }); -// --- Таймер --- +// --- Воркаут --- const timerEl = document.querySelector("#timer"); -const timerBtn = document.querySelector("#timer-btn"); -let running = false; -let startTs = 0; -let elapsed = 0; -let raf = 0; - -function formatTime(ms) { - const sec = Math.floor(ms / 1000); +const workoutSelect = document.querySelector("#workout-select"); +const ftpInput = document.querySelector("#ftp-input"); +const workoutMeta = document.querySelector("#workout-meta"); +const workoutName = document.querySelector("#workout-name"); +const workoutStep = document.querySelector("#workout-step"); +const workoutTarget = document.querySelector("#workout-target"); +const workoutBar = document.querySelector("#workout-bar"); +const workoutStartBtn = document.querySelector("#workout-start"); +const workoutPauseBtn = document.querySelector("#workout-pause"); +const workoutStopBtn = document.querySelector("#workout-stop"); + +let workouts = []; +let workoutPaused = false; + +function formatClock(sec) { const m = Math.floor(sec / 60); - const s = sec % 60; + const s = Math.floor(sec % 60); return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } -function tick() { - elapsed = performance.now() - startTs; - timerEl.textContent = formatTime(elapsed); - if (running) raf = requestAnimationFrame(tick); +async function loadWorkouts() { + try { + workouts = await invoke("list_workouts"); + workoutSelect.replaceChildren(); + const empty = el("option", null, "— выбери воркаут —"); + empty.value = ""; + workoutSelect.append(empty); + for (const w of workouts) { + const opt = el("option", null, `${w.date.slice(0, 10)} · ${w.name} · ${w.duration_min} мин`); + opt.value = w.path; + workoutSelect.append(opt); + } + ftpInput.value = await invoke("get_ftp"); + } catch (e) { + console.error(e); + } } -timerBtn.addEventListener("click", async () => { - running = !running; - if (running) { - startTs = performance.now() - elapsed; - timerBtn.textContent = "стоп"; - tick(); - try { - await invoke("trainer_start"); - } catch (e) { - console.error(e); - } - } else { - cancelAnimationFrame(raf); - timerBtn.textContent = "старт"; - try { - await invoke("trainer_stop"); - } catch (e) { - console.error(e); - } +function selectedWorkout() { + return workouts.find((w) => w.path === workoutSelect.value); +} + +workoutSelect.addEventListener("change", () => { + const w = selectedWorkout(); + workoutName.textContent = w ? w.name : "—"; + workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; +}); + +async function setRunning(active) { + workoutStartBtn.disabled = active; + workoutPauseBtn.disabled = !active; + workoutStopBtn.disabled = !active; + workoutSelect.disabled = active; + ftpInput.disabled = active; + if (!active) { + workoutPaused = false; + workoutPauseBtn.textContent = "пауза"; + workoutStep.textContent = "—"; + workoutTarget.textContent = ""; + workoutBar.style.width = "0%"; + timerEl.textContent = "00:00"; + } +} + +workoutStartBtn.addEventListener("click", async () => { + const w = selectedWorkout(); + const ftp = Number(ftpInput.value); + if (!w || !Number.isFinite(ftp) || ftp <= 0) return; + try { + await invoke("set_ftp", { ftp }); + await invoke("start_workout", { path: w.path }); + await setRunning(true); + } catch (e) { + console.error(e); + } +}); + +workoutPauseBtn.addEventListener("click", async () => { + workoutPaused = !workoutPaused; + workoutPauseBtn.textContent = workoutPaused ? "продолжить" : "пауза"; + try { + await invoke("pause_workout", { paused: workoutPaused }); + } catch (e) { + console.error(e); } }); -// --- Целевая мощность/каденс --- -document.querySelector("#apply-btn").addEventListener("click", async () => { - const powerRaw = document.querySelector("#power-input").value.trim(); - const cadenceRaw = document.querySelector("#cadence-input").value.trim(); - if (powerRaw === "" || cadenceRaw === "") return; - const watts = Number(powerRaw); - const rpm = Number(cadenceRaw); - if (!Number.isFinite(watts) || !Number.isFinite(rpm)) return; - if (watts < 0 || rpm < 0 || !Number.isInteger(watts) || !Number.isInteger(rpm)) return; +workoutStopBtn.addEventListener("click", async () => { try { - await invoke("set_target_power", { watts }); - await invoke("set_target_cadence", { rpm }); + await invoke("stop_workout"); } catch (e) { console.error(e); } + await setRunning(false); +}); + +listen("workout-progress", (event) => { + const p = event.payload; + workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; + workoutTarget.textContent = p.target_watts != null ? `${p.target_watts} W` : ""; + timerEl.textContent = formatClock(p.elapsed_secs); + const pct = p.total_secs > 0 ? (p.elapsed_secs / p.total_secs) * 100 : 0; + workoutBar.style.width = `${Math.min(100, pct)}%`; +}); + +listen("workout-finished", () => { + setRunning(false); }); diff --git a/src/styles.css b/src/styles.css index ddbee3d..be6e2df 100644 --- a/src/styles.css +++ b/src/styles.css @@ -367,3 +367,70 @@ body { .field__input:focus { border-color: var(--c-text); } + +/* --- Выбор воркаута --- */ + +.workout-name { + font-size: var(--text-sm); + font-weight: var(--fw-medium); + color: var(--c-text-muted); + text-align: right; +} + +.workout-picker { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-4); + align-items: end; +} + +.workout-meta { + grid-column: 1 / -1; + font-size: var(--text-sm); + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +/* --- Прогресс воркаута --- */ + +.workout-progress { + display: grid; + gap: var(--space-2); +} + +.workout-progress__head { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: var(--text-sm); +} + +.workout-progress__step { + font-weight: var(--fw-medium); +} + +.workout-progress__target { + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +.workout-progress__track { + height: 6px; + background: var(--c-surface-2); + border-radius: var(--radius-full); + overflow: hidden; +} + +.workout-progress__bar { + height: 100%; + width: 0%; + background: var(--c-accent); + border-radius: var(--radius-full); + transition: width var(--duration-fast) var(--ease); +} + +/* --- Кнопки воркаута --- */ + +.controls--workout { + grid-template-columns: 1fr 1fr 1fr; +} -- 2.36.5 From bbf6e97d87e28849f1905c33a42003ed65c5ea04 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:25:24 +0300 Subject: [PATCH 07/14] test: mock-verify workout engine command sequence (phase 6) --- src-tauri/src/ble/mock.rs | 5 ++ src-tauri/src/lib.rs | 143 ++++++++++++++++++++++++++++---------- 2 files changed, 112 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs index 685b087..8fe1d9f 100644 --- a/src-tauri/src/ble/mock.rs +++ b/src-tauri/src/ble/mock.rs @@ -57,6 +57,11 @@ impl MockSource { commands: Arc::new(Mutex::new(Vec::new())), } } + + /// Записанные команды (для тестов движка воркаута). + pub(crate) fn commands(&self) -> Vec { + self.commands.lock().unwrap().clone() + } } #[async_trait] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 208ce38..d169bc3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -279,44 +279,40 @@ async fn apply_power( watts } -/// Шлёт событие прогресса воркаута на фронт. -fn emit_progress( - app: &AppHandle, +/// Собирает событие прогресса воркаута. +fn progress_event( idx: usize, seg: &workout::Segment, watts: Option, elapsed_total: u64, total: u64, -) { - let _ = app.emit( - "workout-progress", - WorkoutProgressEvent { - step_index: idx, - step_name: seg.text.clone().unwrap_or_default(), - target_watts: watts, - target_cadence: seg.cadence_rpm, - elapsed_secs: elapsed_total, - total_secs: total, - }, - ); +) -> WorkoutProgressEvent { + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: watts, + target_cadence: seg.cadence_rpm, + elapsed_secs: elapsed_total, + total_secs: total, + } } -/// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце. -async fn run_workout( - app: AppHandle, - source: Arc, - device: Device, - segments: Vec, +/// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource. +async fn run_workout_engine( + source: &Arc, + device: &Device, + segments: &[workout::Segment], ftp: u16, - paused: Arc, -) { - if source.request_control(&device).await.is_err() { - let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: false }); - return; + paused: &AtomicBool, + tick_duration: Duration, + mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, +) -> bool { + if source.request_control(device).await.is_err() { + return false; } - let _ = source.command(&device, Command::FtmsStart).await; + let _ = source.command(device, Command::FtmsStart).await; - let total = workout::total_duration_secs(&segments); + let total = workout::total_duration_secs(segments); let mut elapsed_total: u64 = 0; for (idx, seg) in segments.iter().enumerate() { @@ -324,17 +320,17 @@ async fn run_workout( // каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS). if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) { - let _ = source.command(&device, Command::FtmsSetTargetCadence(half)).await; + let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; } let mut last_watts: Option = None; - let mut tick = time::interval(Duration::from_secs(1)); + let mut tick = time::interval(tick_duration); tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay); // t = 0 - let w = apply_power(&source, &device, seg, 0.0, ftp, last_watts).await; + let w = apply_power(source, device, seg, 0.0, ftp, last_watts).await; last_watts = w; - emit_progress(&app, idx, seg, w, elapsed_total, total); + on_progress(progress_event(idx, seg, w, elapsed_total, total)); let mut elapsed_in_seg: u64 = 0; while elapsed_in_seg < dur { @@ -343,15 +339,40 @@ async fn run_workout( continue; } elapsed_in_seg += 1; - let w = apply_power(&source, &device, seg, elapsed_in_seg as f64, ftp, last_watts).await; + let w = apply_power(source, device, seg, elapsed_in_seg as f64, ftp, last_watts).await; last_watts = w; elapsed_total += 1; - emit_progress(&app, idx, seg, w, elapsed_total, total); + on_progress(progress_event(idx, seg, w, elapsed_total, total)); } } - let _ = source.command(&device, Command::FtmsStop).await; - let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true }); + let _ = source.command(device, Command::FtmsStop).await; + true +} + +/// Исполняет воркаут: обёртка run_workout_engine с эмитом событий в приложение. +async fn run_workout( + app: AppHandle, + source: Arc, + device: Device, + segments: Vec, + ftp: u16, + paused: Arc, +) { + let app_progress = app.clone(); + let ok = run_workout_engine( + &source, + &device, + &segments, + ftp, + paused.as_ref(), + Duration::from_secs(1), + move |p| { + let _ = app_progress.emit("workout-progress", p); + }, + ) + .await; + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok }); } /// Запустить воркаут (по имени файла из каталога воркаутов). @@ -638,4 +659,54 @@ mod tests { assert!(saw); assert_eq!(seen.len(), 2); } + + #[tokio::test] + async fn workout_engine_sends_expected_commands() { + let mock = Arc::new(MockSource::new()); + let source: Arc = mock.clone(); + let devices = source.scan(0).await.unwrap(); + let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap().clone(); + + // короткий воркаут: фикс-сегмент + ramp-сегмент + let segments = vec![ + workout::Segment { + text: Some("Warmup".into()), + duration_secs: 1, + power: Some(workout::PowerTarget::Fixed { percent: 50.0 }), + cadence_rpm: Some(85), + }, + workout::Segment { + text: Some("Ramp".into()), + duration_secs: 2, + power: Some(workout::PowerTarget::Ramp { from: 100.0, to: 200.0 }), + cadence_rpm: None, + }, + ]; + + let paused = Arc::new(AtomicBool::new(false)); + let ok = run_workout_engine( + &source, + &trainer, + &segments, + 250, + paused.as_ref(), + Duration::from_millis(20), + |_| {}, + ) + .await; + assert!(ok); + + assert_eq!( + mock.commands(), + vec![ + Command::FtmsStart, + Command::FtmsSetTargetCadence(170), // 85 rpm * 2 + Command::FtmsSetTargetPower(125), // 50% of 250 + Command::FtmsSetTargetPower(250), // 100% (ramp start) + Command::FtmsSetTargetPower(375), // 150% + Command::FtmsSetTargetPower(500), // 200% (ramp end) + Command::FtmsStop, + ] + ); + } } -- 2.36.5 From 505cce19433ee35c9b22a1f50a784d8d7edafb83 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:46:45 +0300 Subject: [PATCH 08/14] fix: mock trainer emits cadence along with power --- src-tauri/src/ble/mock.rs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs index 8fe1d9f..1c7004c 100644 --- a/src-tauri/src/ble/mock.rs +++ b/src-tauri/src/ble/mock.rs @@ -75,11 +75,17 @@ impl SensorSource for MockSource { { let types = get_device_types(device); let s: Pin + Send>> = if types.contains(&DeviceType::Trainer) { - // мощность рампой 150..300 Вт + // мощность 150..300 Вт + каденс 85..95 rpm (как FTMS 0x2AD2 — оба поля) stream::unfold(0u16, |i| async move { - tokio::time::sleep(Duration::from_millis(500)).await; - let watts = 150 + ((i % 16) * 10) as i16; - Some((Telemetry::Power { watts }, i.wrapping_add(1))) + tokio::time::sleep(Duration::from_millis(250)).await; + let item = if i % 2 == 0 { + let watts = 150 + ((i / 2 % 16) * 10) as i16; + Telemetry::Power { watts } + } else { + let rpm = 85.0 + (i / 2 % 10) as f32; + Telemetry::Cadence { rpm } + }; + Some((item, i.wrapping_add(1))) }) .boxed() } else if types.contains(&DeviceType::CadSensor) { @@ -142,4 +148,23 @@ mod tests { let first = stream.next().await; assert!(matches!(first, Some(Telemetry::HeartRate { .. }))); } + + #[tokio::test] + async fn test_mock_trainer_emits_power_and_cadence() { + let src = MockSource::new(); + let devices = src.scan(0).await.unwrap(); + let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap(); + let mut stream = src.stream(trainer).await.unwrap(); + let mut saw_power = false; + let mut saw_cadence = false; + for _ in 0..8 { + match stream.next().await { + Some(Telemetry::Power { .. }) => saw_power = true, + Some(Telemetry::Cadence { .. }) => saw_cadence = true, + _ => {} + } + } + assert!(saw_power, "тренер должен эмитить мощность"); + assert!(saw_cadence, "тренер должен эмитить каденс"); + } } -- 2.36.5 From ee913002ff0784082c43fa8ab587898f0ed728bc Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:50:32 +0300 Subject: [PATCH 09/14] fix: silence dead_code warning on MockSource::commands --- src-tauri/src/ble/mock.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs index 1c7004c..823d982 100644 --- a/src-tauri/src/ble/mock.rs +++ b/src-tauri/src/ble/mock.rs @@ -58,7 +58,8 @@ impl MockSource { } } - /// Записанные команды (для тестов движка воркаута). + /// Записанные команды — читаются только в тестах движка воркаута. + #[allow(dead_code)] pub(crate) fn commands(&self) -> Vec { self.commands.lock().unwrap().clone() } -- 2.36.5 From b6ea23f02d640bab57fe2418c72f49bd0bbd94ac Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 17:09:26 +0300 Subject: [PATCH 10/14] feat: show target power/cadence with off-target warning --- src/index.html | 9 ++++-- src/main.js | 76 ++++++++++++++++++++++++++++++++++++++++++++++---- src/styles.css | 21 ++++++++++---- src/tokens.css | 1 + 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/index.html b/src/index.html index aa0a9d7..69ca005 100644 --- a/src/index.html +++ b/src/index.html @@ -54,15 +54,19 @@
-
+
мощность — W + +
-
+
каденс — rpm + +
чсс @@ -74,7 +78,6 @@
— -
diff --git a/src/main.js b/src/main.js index 885d932..c853c17 100644 --- a/src/main.js +++ b/src/main.js @@ -151,9 +151,17 @@ listen("battery", (event) => { listen("telemetry", (event) => { const t = event.payload.telemetry; - if (t.Power) setMetric("power-value", t.Power.watts); - else if (t.Cadence) setMetric("cadence-value", Math.round(t.Cadence.rpm)); - else if (t.HeartRate) setMetric("hr-value", t.HeartRate.bpm); + if (t.Power) { + actualWatts = t.Power.watts; + setMetric("power-value", actualWatts); + updatePowerDeviation(); + } else if (t.Cadence) { + actualCadence = Math.round(t.Cadence.rpm); + setMetric("cadence-value", actualCadence); + updateCadenceDeviation(); + } else if (t.HeartRate) { + setMetric("hr-value", t.HeartRate.bpm); + } }); function applyStatus(sel, category, status) { @@ -190,12 +198,65 @@ const ftpInput = document.querySelector("#ftp-input"); const workoutMeta = document.querySelector("#workout-meta"); const workoutName = document.querySelector("#workout-name"); const workoutStep = document.querySelector("#workout-step"); -const workoutTarget = document.querySelector("#workout-target"); const workoutBar = document.querySelector("#workout-bar"); const workoutStartBtn = document.querySelector("#workout-start"); const workoutPauseBtn = document.querySelector("#workout-pause"); const workoutStopBtn = document.querySelector("#workout-stop"); +// --- Отклонение от цели --- +const powerMetric = document.querySelector("#power-metric"); +const powerTarget = document.querySelector("#power-target"); +const powerWarn = document.querySelector("#power-warn"); +const cadenceMetric = document.querySelector("#cadence-metric"); +const cadenceTarget = document.querySelector("#cadence-target"); +const cadenceWarn = document.querySelector("#cadence-warn"); + +const POWER_TOL_PCT = 0.1; +const POWER_TOL_ABS = 15; // Вт +const CADENCE_TOL_ABS = 5; // rpm + +let targetWatts = null; +let targetCadence = null; +let actualWatts = null; +let actualCadence = null; + +function powerTolerance(target) { + return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT)); +} + +function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance) { + targetEl.textContent = target != null ? `цель ${target}` : ""; + if (actual == null || target == null) { + metricEl.classList.remove("metric--off"); + warnEl.hidden = true; + return; + } + const diff = actual - target; + if (Math.abs(diff) > tolerance) { + metricEl.classList.add("metric--off"); + warnEl.textContent = diff > 0 ? "выше цели" : "ниже цели"; + warnEl.hidden = false; + } else { + metricEl.classList.remove("metric--off"); + warnEl.hidden = true; + } +} + +function updatePowerDeviation() { + updateDeviation(powerMetric, powerTarget, powerWarn, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0)); +} + +function updateCadenceDeviation() { + updateDeviation(cadenceMetric, cadenceTarget, cadenceWarn, actualCadence, targetCadence, CADENCE_TOL_ABS); +} + +function clearTargets() { + targetWatts = null; + targetCadence = null; + updatePowerDeviation(); + updateCadenceDeviation(); +} + let workouts = []; let workoutPaused = false; @@ -243,9 +304,9 @@ async function setRunning(active) { workoutPaused = false; workoutPauseBtn.textContent = "пауза"; workoutStep.textContent = "—"; - workoutTarget.textContent = ""; workoutBar.style.width = "0%"; timerEl.textContent = "00:00"; + clearTargets(); } } @@ -284,7 +345,10 @@ workoutStopBtn.addEventListener("click", async () => { listen("workout-progress", (event) => { const p = event.payload; workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; - workoutTarget.textContent = p.target_watts != null ? `${p.target_watts} W` : ""; + targetWatts = p.target_watts; + targetCadence = p.target_cadence; + updatePowerDeviation(); + updateCadenceDeviation(); timerEl.textContent = formatClock(p.elapsed_secs); const pct = p.total_secs > 0 ? (p.elapsed_secs / p.total_secs) * 100 : 0; workoutBar.style.width = `${Math.min(100, pct)}%`; diff --git a/src/styles.css b/src/styles.css index be6e2df..ec8c2eb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -332,6 +332,22 @@ body { color: var(--c-text-muted); } +.metric__target { + font-size: var(--text-xs); + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +.metric__warn { + font-size: var(--text-xs); + font-weight: var(--fw-medium); + color: var(--c-danger); +} + +.metric--off .metric__value { + color: var(--c-danger); +} + /* --- Управление --- */ .controls { @@ -409,11 +425,6 @@ body { font-weight: var(--fw-medium); } -.workout-progress__target { - color: var(--c-text-muted); - font-variant-numeric: tabular-nums; -} - .workout-progress__track { height: 6px; background: var(--c-surface-2); diff --git a/src/tokens.css b/src/tokens.css index e4e66ef..6deebc6 100644 --- a/src/tokens.css +++ b/src/tokens.css @@ -25,6 +25,7 @@ --c-text-faint: hsl(0 0% 40%); /* едва заметный */ --c-accent: hsl(0 0% 93%); /* акцент (инверсия) */ --c-accent-contrast: hsl(0 0% 4%); + --c-danger: hsl(0 72% 58%); /* отклонение от цели / ошибка */ /* --- Отступы (база 4px) --- */ --space-1: 0.25rem; /* 4px */ -- 2.36.5 From e1a1da6835caa32e58660914b85a0c7c407143ea Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 17:20:04 +0300 Subject: [PATCH 11/14] fix: prominent target values and no layout jump on warnings --- src/index.html | 14 ++++++-------- src/main.js | 17 ++++++++--------- src/styles.css | 30 +++++++++++++++++++----------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/index.html b/src/index.html index 69ca005..2124f1d 100644 --- a/src/index.html +++ b/src/index.html @@ -56,17 +56,15 @@
мощность - — - W - - + — + — +
каденс - — - rpm - - + — + — +
чсс diff --git a/src/main.js b/src/main.js index c853c17..282a888 100644 --- a/src/main.js +++ b/src/main.js @@ -153,11 +153,11 @@ listen("telemetry", (event) => { const t = event.payload.telemetry; if (t.Power) { actualWatts = t.Power.watts; - setMetric("power-value", actualWatts); + setMetric("power-value", `${actualWatts} W`); updatePowerDeviation(); } else if (t.Cadence) { actualCadence = Math.round(t.Cadence.rpm); - setMetric("cadence-value", actualCadence); + setMetric("cadence-value", `${actualCadence} rpm`); updateCadenceDeviation(); } else if (t.HeartRate) { setMetric("hr-value", t.HeartRate.bpm); @@ -224,30 +224,29 @@ function powerTolerance(target) { return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT)); } -function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance) { - targetEl.textContent = target != null ? `цель ${target}` : ""; +function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance, unit) { + targetEl.textContent = target != null ? `цель ${target} ${unit}` : "—"; if (actual == null || target == null) { metricEl.classList.remove("metric--off"); - warnEl.hidden = true; + warnEl.textContent = ""; return; } const diff = actual - target; if (Math.abs(diff) > tolerance) { metricEl.classList.add("metric--off"); warnEl.textContent = diff > 0 ? "выше цели" : "ниже цели"; - warnEl.hidden = false; } else { metricEl.classList.remove("metric--off"); - warnEl.hidden = true; + warnEl.textContent = ""; } } function updatePowerDeviation() { - updateDeviation(powerMetric, powerTarget, powerWarn, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0)); + updateDeviation(powerMetric, powerTarget, powerWarn, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0), "W"); } function updateCadenceDeviation() { - updateDeviation(cadenceMetric, cadenceTarget, cadenceWarn, actualCadence, targetCadence, CADENCE_TOL_ABS); + updateDeviation(cadenceMetric, cadenceTarget, cadenceWarn, actualCadence, targetCadence, CADENCE_TOL_ABS, "rpm"); } function clearTargets() { diff --git a/src/styles.css b/src/styles.css index ec8c2eb..8dd92f2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -316,36 +316,44 @@ body { } .metric__value { - font-size: var(--text-3xl); + font-size: var(--text-xl); font-weight: var(--fw-semibold); line-height: 1; font-variant-numeric: tabular-nums; } -.metric--cadence .metric__value, -.metric--hr .metric__value { - font-size: var(--text-xl); -} - .metric__unit { font-size: var(--text-sm); color: var(--c-text-muted); } +/* целевое значение — главное */ .metric__target { - font-size: var(--text-xs); + font-size: var(--text-2xl); + font-weight: var(--fw-semibold); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +/* фактическое значение — вторичное, краснеет при отклонении */ +.metric__actual { + font-size: var(--text-base); color: var(--c-text-muted); font-variant-numeric: tabular-nums; } -.metric__warn { - font-size: var(--text-xs); - font-weight: var(--fw-medium); +.metric--off .metric__actual { color: var(--c-danger); + font-weight: var(--fw-semibold); } -.metric--off .metric__value { +/* предупреждение — резервируем строку, чтобы не прыгало */ +.metric__warn { + min-height: var(--text-base); + font-size: var(--text-xs); + font-weight: var(--fw-medium); color: var(--c-danger); + line-height: 1.2; } /* --- Управление --- */ -- 2.36.5 From 8f90af6cf27da8291664ee37360e10c6b380e528 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 17:32:18 +0300 Subject: [PATCH 12/14] fix: remove annoiying messages --- src/main.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main.js b/src/main.js index 282a888..0448ba7 100644 --- a/src/main.js +++ b/src/main.js @@ -225,7 +225,7 @@ function powerTolerance(target) { } function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance, unit) { - targetEl.textContent = target != null ? `цель ${target} ${unit}` : "—"; + targetEl.textContent = target != null ? `${target} ${unit}` : "—"; if (actual == null || target == null) { metricEl.classList.remove("metric--off"); warnEl.textContent = ""; @@ -234,10 +234,8 @@ function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance, const diff = actual - target; if (Math.abs(diff) > tolerance) { metricEl.classList.add("metric--off"); - warnEl.textContent = diff > 0 ? "выше цели" : "ниже цели"; } else { metricEl.classList.remove("metric--off"); - warnEl.textContent = ""; } } -- 2.36.5 From 08880562682c13274545da409998d75e6d450c7f Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 17:38:16 +0300 Subject: [PATCH 13/14] feat: real-time power and heart rate graph with power zones --- src/index.html | 4 ++ src/main.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++++ src/styles.css | 11 ++++ 3 files changed, 172 insertions(+) diff --git a/src/index.html b/src/index.html index 2124f1d..8c28b29 100644 --- a/src/index.html +++ b/src/index.html @@ -73,6 +73,10 @@
+
+ +
+
— diff --git a/src/main.js b/src/main.js index 0448ba7..ecad020 100644 --- a/src/main.js +++ b/src/main.js @@ -125,6 +125,7 @@ async function connect(deviceId, btn, category, card) { startBtn.addEventListener("click", async () => { connectScreen.hidden = true; workoutScreen.hidden = false; + sizeGraph(); await loadWorkouts(); }); @@ -155,12 +156,16 @@ listen("telemetry", (event) => { actualWatts = t.Power.watts; setMetric("power-value", `${actualWatts} W`); updatePowerDeviation(); + samplePower(actualWatts); + scheduleDraw(); } else if (t.Cadence) { actualCadence = Math.round(t.Cadence.rpm); setMetric("cadence-value", `${actualCadence} rpm`); updateCadenceDeviation(); } else if (t.HeartRate) { setMetric("hr-value", t.HeartRate.bpm); + sampleHr(t.HeartRate.bpm); + scheduleDraw(); } }); @@ -254,6 +259,158 @@ function clearTargets() { updateCadenceDeviation(); } +// --- График мощности и пульса --- +const graph = document.querySelector("#graph"); + +const ZONE_COLORS = [ + "#b0bec5", // Z1 — восстановление + "#42a5f5", // Z2 — выносливость + "#66bb6a", // Z3 — темп + "#ffca28", // Z4 — порог + "#ff9800", // Z5 — VO2max + "#ef5350", // Z6 — анаэробная + "#ab47bc", // Z7 — нейромышечная +]; + +const GRAPH_WINDOW_MS = 30 * 1000; +const powerHistory = []; +const hrHistory = []; +let graphDirty = false; + +function powerZone(watts, ftp) { + const pct = (watts / ftp) * 100; + if (pct < 55) return 0; + if (pct < 76) return 1; + if (pct < 91) return 2; + if (pct < 106) return 3; + if (pct < 121) return 4; + if (pct < 151) return 5; + return 6; +} + +function currentFtp() { + const v = Number(ftpInput.value); + return Number.isFinite(v) && v > 0 ? v : 250; +} + +function sizeGraph() { + const dpr = window.devicePixelRatio || 1; + const w = graph.clientWidth; + const h = graph.clientHeight; + if (w === 0 || h === 0) return; + graph.width = Math.round(w * dpr); + graph.height = Math.round(h * dpr); +} + +function samplePower(watts) { + const now = performance.now(); + powerHistory.push({ t: now, watts }); + while (powerHistory.length && powerHistory[0].t < now - GRAPH_WINDOW_MS) powerHistory.shift(); +} + +function sampleHr(bpm) { + const now = performance.now(); + hrHistory.push({ t: now, bpm }); + while (hrHistory.length && hrHistory[0].t < now - GRAPH_WINDOW_MS) hrHistory.shift(); +} + +function scheduleDraw() { + if (graphDirty) return; + graphDirty = true; + requestAnimationFrame(() => { + graphDirty = false; + drawGraph(); + }); +} + +function drawGraph() { + const ctx = graph.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + const w = graph.clientWidth; + const h = graph.clientHeight; + if (w === 0 || h === 0) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const now = performance.now(); + const cutoff = now - GRAPH_WINDOW_MS; + const power = powerHistory.filter((s) => s.t >= cutoff); + const hr = hrHistory.filter((s) => s.t >= cutoff); + + const maxPower = Math.max(300, Math.ceil(Math.max(...power.map((s) => s.watts), 0) / 50) * 50); + const maxHr = Math.max(160, Math.ceil(Math.max(...hr.map((s) => s.bpm), 0) / 10) * 10); + + const xFor = (t) => w - ((now - t) / GRAPH_WINDOW_MS) * w; + const yPower = (v) => h - (v / maxPower) * h; + const yHr = (v) => h - (v / maxHr) * h; + + // сетка мощности + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.fillStyle = "rgba(255,255,255,0.35)"; + ctx.font = "10px Inter, system-ui, sans-serif"; + ctx.lineWidth = 1; + for (let g = 0; g <= maxPower; g += 50) { + const y = yPower(g); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + ctx.fillText(String(g), 4, y - 2); + } + + // пульс — пунктирная линия + if (hr.length >= 2) { + ctx.strokeStyle = "#ef5350"; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + hr.forEach((s, i) => { + const x = xFor(s.t); + const y = yHr(s.bpm); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.stroke(); + ctx.setLineDash([]); + } + + // мощность — заливка по зонам + if (power.length >= 2) { + const ftp = currentFtp(); + for (let i = 0; i < power.length - 1; i++) { + const a = power[i]; + const b = power[i + 1]; + const color = ZONE_COLORS[powerZone(a.watts, ftp)]; + const xa = xFor(a.t); + const ya = yPower(a.watts); + const xb = xFor(b.t); + const yb = yPower(b.watts); + + ctx.fillStyle = color; + ctx.globalAlpha = 0.3; + ctx.beginPath(); + ctx.moveTo(xa, h); + ctx.lineTo(xa, ya); + ctx.lineTo(xb, yb); + ctx.lineTo(xb, h); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(xa, ya); + ctx.lineTo(xb, yb); + ctx.stroke(); + } + } +} + +window.addEventListener("resize", () => { + sizeGraph(); + scheduleDraw(); +}); + let workouts = []; let workoutPaused = false; diff --git a/src/styles.css b/src/styles.css index 8dd92f2..36f1a3c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -356,6 +356,17 @@ body { line-height: 1.2; } +/* --- График мощности/пульса --- */ + +.graph canvas { + display: block; + width: 100%; + height: 140px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); +} + /* --- Управление --- */ .controls { -- 2.36.5 From 3c06100a23ac9549e39b79dd8f4b05211fe789d3 Mon Sep 17 00:00:00 2001 From: lambda Date: Wed, 28 Oct 2026 14:39:43 +0300 Subject: [PATCH 14/14] fix: PR review changes --- src-tauri/src/workout.rs | 25 ++++++++++++++++++++++++- src/index.html | 2 -- src/main.js | 9 +++------ src/styles.css | 9 --------- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/workout.rs b/src-tauri/src/workout.rs index 2549362..1889a7d 100644 --- a/src-tauri/src/workout.rs +++ b/src-tauri/src/workout.rs @@ -99,6 +99,8 @@ struct RawStep { #[derive(Deserialize)] struct RawPower { + #[serde(default)] + units: Option, #[serde(default)] value: Option, #[serde(default)] @@ -160,6 +162,12 @@ fn convert_step(raw: RawStep) -> WorkoutStep { /// линейный ramp от свободного диапазона. Предполагаем `units == "%ftp"` /// (все скачанные воркауты используют %ftp); абсолютные ватты не поддерживаются. fn convert_power(p: RawPower, ramp: bool) -> Option { + // Отвергаем воркауты в абсолютных ваттах: перевод watts → %FTP требует FTP, + // которого нет на этапе парсинга. Принимаем только %ftp (units == None + // трактуем как %ftp — все скачанные воркауты используют %ftp). + if matches!(p.units.as_deref(), Some(u) if u != "%ftp") { + return None; + } if let Some(v) = p.value { return Some(PowerTarget::Fixed { percent: v }); } @@ -199,7 +207,11 @@ pub fn total_duration_secs(segments: &[Segment]) -> u64 { } /// Целевая мощность (%FTP) в момент `elapsed_secs` внутри шага. -/// Fixed — константа; Ramp — линейная интерполяция from→to; Range — середина. +/// Fixed — константа; Ramp — линейная интерполяция from→to. +/// +/// Range (свободный диапазон low..high) сводится к середине: в ERG-режиме +/// нужна конкретная цель, середина — осознанный дефолт. Альтернативы: не слать +/// ERG вовсе (свободный ход) или слать low как «пол». pub fn target_percent(power: &PowerTarget, elapsed_secs: f64, duration_secs: u32) -> f64 { match power { PowerTarget::Fixed { percent } => *percent, @@ -330,6 +342,17 @@ mod tests { assert_eq!(w.steps[0].cadence_rpm, Some(85)); } + #[test] + fn rejects_non_ftp_power_units() { + // units:"watts" нельзя перевести в %FTP без FTP на этапе парсинга — + // шаг должен терять цель мощности, а не трактовать ватты как проценты. + let json = r#"{"name":"W","workout_doc":{"steps":[ + {"text":"X","duration":60,"power":{"units":"watts","value":200}} + ]}}"#; + let w = parse_workout(json).unwrap(); + assert_eq!(w.steps[0].power, None); + } + #[test] fn flatten_expands_repeats() { let w = parse_workout(EVENT).unwrap(); diff --git a/src/index.html b/src/index.html index 8c28b29..9d72778 100644 --- a/src/index.html +++ b/src/index.html @@ -58,13 +58,11 @@ мощность — — -
каденс — — -
чсс diff --git a/src/main.js b/src/main.js index ecad020..79bb870 100644 --- a/src/main.js +++ b/src/main.js @@ -211,10 +211,8 @@ const workoutStopBtn = document.querySelector("#workout-stop"); // --- Отклонение от цели --- const powerMetric = document.querySelector("#power-metric"); const powerTarget = document.querySelector("#power-target"); -const powerWarn = document.querySelector("#power-warn"); const cadenceMetric = document.querySelector("#cadence-metric"); const cadenceTarget = document.querySelector("#cadence-target"); -const cadenceWarn = document.querySelector("#cadence-warn"); const POWER_TOL_PCT = 0.1; const POWER_TOL_ABS = 15; // Вт @@ -229,11 +227,10 @@ function powerTolerance(target) { return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT)); } -function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance, unit) { +function updateDeviation(metricEl, targetEl, actual, target, tolerance, unit) { targetEl.textContent = target != null ? `${target} ${unit}` : "—"; if (actual == null || target == null) { metricEl.classList.remove("metric--off"); - warnEl.textContent = ""; return; } const diff = actual - target; @@ -245,11 +242,11 @@ function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance, } function updatePowerDeviation() { - updateDeviation(powerMetric, powerTarget, powerWarn, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0), "W"); + updateDeviation(powerMetric, powerTarget, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0), "W"); } function updateCadenceDeviation() { - updateDeviation(cadenceMetric, cadenceTarget, cadenceWarn, actualCadence, targetCadence, CADENCE_TOL_ABS, "rpm"); + updateDeviation(cadenceMetric, cadenceTarget, actualCadence, targetCadence, CADENCE_TOL_ABS, "rpm"); } function clearTargets() { diff --git a/src/styles.css b/src/styles.css index 36f1a3c..b65f82a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -347,15 +347,6 @@ body { font-weight: var(--fw-semibold); } -/* предупреждение — резервируем строку, чтобы не прыгало */ -.metric__warn { - min-height: var(--text-base); - font-size: var(--text-xs); - font-weight: var(--fw-medium); - color: var(--c-danger); - line-height: 1.2; -} - /* --- График мощности/пульса --- */ .graph canvas { -- 2.36.5