Compare commits

..

No commits in common. '08880562682c13274545da409998d75e6d450c7f' and '981747fc50c427b6f3437198895e5253b28058a1' have entirely different histories.

@ -57,12 +57,6 @@ impl MockSource {
commands: Arc::new(Mutex::new(Vec::new())), commands: Arc::new(Mutex::new(Vec::new())),
} }
} }
/// Записанные команды — читаются только в тестах движка воркаута.
#[allow(dead_code)]
pub(crate) fn commands(&self) -> Vec<Command> {
self.commands.lock().unwrap().clone()
}
} }
#[async_trait] #[async_trait]
@ -76,17 +70,11 @@ impl SensorSource for MockSource {
{ {
let types = get_device_types(device); let types = get_device_types(device);
let s: Pin<Box<dyn Stream<Item = Telemetry> + Send>> = if types.contains(&DeviceType::Trainer) { let s: Pin<Box<dyn Stream<Item = Telemetry> + Send>> = if types.contains(&DeviceType::Trainer) {
// мощность 150..300 Вт + каденс 85..95 rpm (как FTMS 0x2AD2 — оба поля) // мощность рампой 150..300 Вт
stream::unfold(0u16, |i| async move { stream::unfold(0u16, |i| async move {
tokio::time::sleep(Duration::from_millis(250)).await; tokio::time::sleep(Duration::from_millis(500)).await;
let item = if i % 2 == 0 { let watts = 150 + ((i % 16) * 10) as i16;
let watts = 150 + ((i / 2 % 16) * 10) as i16; Some((Telemetry::Power { watts }, i.wrapping_add(1)))
Telemetry::Power { watts }
} else {
let rpm = 85.0 + (i / 2 % 10) as f32;
Telemetry::Cadence { rpm }
};
Some((item, i.wrapping_add(1)))
}) })
.boxed() .boxed()
} else if types.contains(&DeviceType::CadSensor) { } else if types.contains(&DeviceType::CadSensor) {
@ -149,23 +137,4 @@ mod tests {
let first = stream.next().await; let first = stream.next().await;
assert!(matches!(first, Some(Telemetry::HeartRate { .. }))); 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, "тренер должен эмитить каденс");
}
} }

@ -7,7 +7,6 @@ mod commands;
mod workout; mod workout;
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration; use std::time::Duration;
@ -20,7 +19,7 @@ use crate::ble::{BleSource, MockSource, SensorSource};
use crate::commands::Command; use crate::commands::Command;
use crate::device::Device; use crate::device::Device;
use crate::device_repo::{repo_from_devices, Repo}; use crate::device_repo::{repo_from_devices, Repo};
use crate::state::{State, TrainerState, WorkoutHandle}; use crate::state::{State, TrainerState};
use crate::telemetry::Telemetry; use crate::telemetry::Telemetry;
use btleplug::api::Manager as ApiManager; use btleplug::api::Manager as ApiManager;
use btleplug::platform::Manager as BleManager; use btleplug::platform::Manager as BleManager;
@ -54,21 +53,6 @@ struct ConnectionEvent {
status: &'static str, // "reconnecting" | "reconnected" | "gave_up" status: &'static str, // "reconnecting" | "reconnected" | "gave_up"
} }
#[derive(Clone, Serialize)]
struct WorkoutProgressEvent {
step_index: usize,
step_name: String,
target_watts: Option<i16>,
target_cadence: Option<u16>,
elapsed_secs: u64,
total_secs: u64,
}
#[derive(Clone, Serialize)]
struct WorkoutFinishedEvent {
ok: bool,
}
#[tauri::command] #[tauri::command]
async fn init(app: AppHandle) -> Result<(), String> { async fn init(app: AppHandle) -> Result<(), String> {
let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?;
@ -261,179 +245,6 @@ fn get_ftp(app: AppHandle) -> u16 {
*app.state::<State>().ftp.lock().unwrap() *app.state::<State>().ftp.lock().unwrap()
} }
/// Вычисляет и отправляет целевую мощность сегмента, если она изменилась.
async fn apply_power(
source: &Arc<dyn SensorSource>,
device: &Device,
seg: &workout::Segment,
elapsed_secs: f64,
ftp: u16,
last_watts: Option<i16>,
) -> Option<i16> {
let watts = seg.power.as_ref().map(|p| {
workout::percent_to_watts(workout::target_percent(p, elapsed_secs, seg.duration_secs), ftp)
});
if let Some(w) = watts.filter(|w| Some(*w) != last_watts) {
let _ = source.command(device, Command::FtmsSetTargetPower(w)).await;
}
watts
}
/// Собирает событие прогресса воркаута.
fn progress_event(
idx: usize,
seg: &workout::Segment,
watts: Option<i16>,
elapsed_total: u64,
total: u64,
) -> 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,
}
}
/// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource.
async fn run_workout_engine(
source: &Arc<dyn SensorSource>,
device: &Device,
segments: &[workout::Segment],
ftp: u16,
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 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;
// каденс — константа сегмента, шлём один раз (в 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<i16> = None;
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;
last_watts = w;
on_progress(progress_event(idx, seg, w, elapsed_total, 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 = apply_power(source, device, seg, elapsed_in_seg as f64, ftp, last_watts).await;
last_watts = w;
elapsed_total += 1;
on_progress(progress_event(idx, seg, w, elapsed_total, total));
}
}
let _ = source.command(device, Command::FtmsStop).await;
true
}
/// Исполняет воркаут: обёртка run_workout_engine с эмитом событий в приложение.
async fn run_workout(
app: AppHandle,
source: Arc<dyn SensorSource>,
device: Device,
segments: Vec<workout::Segment>,
ftp: u16,
paused: Arc<AtomicBool>,
) {
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 });
}
/// Запустить воркаут (по имени файла из каталога воркаутов).
#[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::<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::<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::<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. /// Why the watchdog loop stopped consuming a stream.
enum StreamOutcome { enum StreamOutcome {
/// The stream ended (peripheral disconnected / notifications closed). /// The stream ended (peripheral disconnected / notifications closed).
@ -592,7 +403,6 @@ pub fn run() {
streams: Mutex::new(std::collections::HashMap::new()), streams: Mutex::new(std::collections::HashMap::new()),
trainer_state: Mutex::new(TrainerState::default()), trainer_state: Mutex::new(TrainerState::default()),
ftp: Mutex::new(state::DEFAULT_FTP), ftp: Mutex::new(state::DEFAULT_FTP),
workout: Mutex::new(None),
}); });
Ok::<(), Box<dyn std::error::Error>>(()) Ok::<(), Box<dyn std::error::Error>>(())
}) })
@ -607,10 +417,7 @@ pub fn run() {
list_workouts, list_workouts,
get_workout, get_workout,
set_ftp, set_ftp,
get_ftp, get_ftp
start_workout,
stop_workout,
pause_workout
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
@ -659,54 +466,4 @@ mod tests {
assert!(saw); assert!(saw);
assert_eq!(seen.len(), 2); assert_eq!(seen.len(), 2);
} }
#[tokio::test]
async fn workout_engine_sends_expected_commands() {
let mock = Arc::new(MockSource::new());
let source: Arc<dyn SensorSource> = 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,
]
);
}
} }

@ -1,5 +1,4 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use tauri::async_runtime::JoinHandle; use tauri::async_runtime::JoinHandle;
@ -18,12 +17,6 @@ pub struct TrainerState {
pub running: bool, pub running: bool,
} }
/// Ручка запущенного воркаута: задача + флаг паузы.
pub struct WorkoutHandle {
pub task: JoinHandle<()>,
pub paused: Arc<AtomicBool>,
}
pub struct State { pub struct State {
pub source: Arc<dyn SensorSource>, pub source: Arc<dyn SensorSource>,
/// Все найденные при скане устройства (для поиска по id при подключении). /// Все найденные при скане устройства (для поиска по id при подключении).
@ -36,6 +29,4 @@ pub struct State {
pub trainer_state: Mutex<TrainerState>, pub trainer_state: Mutex<TrainerState>,
/// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты. /// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты.
pub ftp: Mutex<u16>, pub ftp: Mutex<u16>,
/// Запущенный воркаут (если идёт).
pub workout: Mutex<Option<WorkoutHandle>>,
} }

@ -198,28 +198,6 @@ pub fn total_duration_secs(segments: &[Segment]) -> u64 {
segments.iter().map(|s| s.duration_secs as u64).sum() 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. /// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd.
pub fn workouts_dir() -> PathBuf { pub fn workouts_dir() -> PathBuf {
std::env::var("RIDEIN_WORKOUTS_DIR") std::env::var("RIDEIN_WORKOUTS_DIR")
@ -413,30 +391,4 @@ mod tests {
assert!(read_workout("../etc/passwd").is_err()); assert!(read_workout("../etc/passwd").is_err());
assert!(read_workout("a/b.json").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);
}
} }

@ -38,33 +38,19 @@
<main class="screen" id="workout" hidden> <main class="screen" id="workout" hidden>
<header class="workout-header"> <header class="workout-header">
<div class="timer" id="timer">00:00</div> <div class="timer" id="timer">00:00</div>
<div class="workout-name" id="workout-name">—</div> <button class="btn btn--ghost" id="timer-btn">старт</button>
</header> </header>
<section class="workout-picker">
<label class="field">
<span class="field__label">воркаут</span>
<select class="field__input" id="workout-select"></select>
</label>
<label class="field">
<span class="field__label">ftp (Вт)</span>
<input class="field__input" id="ftp-input" type="number" value="250" min="1" />
</label>
<p class="workout-meta" id="workout-meta"></p>
</section>
<section class="metrics"> <section class="metrics">
<div class="metric metric--power" id="power-metric"> <div class="metric metric--power">
<span class="metric__label">мощность</span> <span class="metric__label">мощность</span>
<span class="metric__target" id="power-target">—</span> <span class="metric__value" id="power-value">—</span>
<span class="metric__actual" id="power-value">—</span> <span class="metric__unit">W</span>
<span class="metric__warn" id="power-warn"></span>
</div> </div>
<div class="metric metric--cadence" id="cadence-metric"> <div class="metric metric--cadence">
<span class="metric__label">каденс</span> <span class="metric__label">каденс</span>
<span class="metric__target" id="cadence-target">—</span> <span class="metric__value" id="cadence-value">—</span>
<span class="metric__actual" id="cadence-value">—</span> <span class="metric__unit">rpm</span>
<span class="metric__warn" id="cadence-warn"></span>
</div> </div>
<div class="metric metric--hr"> <div class="metric metric--hr">
<span class="metric__label">чсс</span> <span class="metric__label">чсс</span>
@ -73,23 +59,16 @@
</div> </div>
</section> </section>
<section class="graph"> <section class="controls">
<canvas id="graph"></canvas> <label class="field">
</section> <span class="field__label">целевая мощность</span>
<input class="field__input" id="power-input" type="number" value="200" min="0" />
<section class="workout-progress"> </label>
<div class="workout-progress__head"> <label class="field">
<span class="workout-progress__step" id="workout-step">—</span> <span class="field__label">целевой каденс</span>
</div> <input class="field__input" id="cadence-input" type="number" value="90" min="0" />
<div class="workout-progress__track"> </label>
<div class="workout-progress__bar" id="workout-bar"></div> <button class="btn btn--primary" id="apply-btn">применить</button>
</div>
</section>
<section class="controls controls--workout">
<button class="btn btn--primary" id="workout-start">старт</button>
<button class="btn btn--ghost" id="workout-pause" disabled>пауза</button>
<button class="btn btn--ghost" id="workout-stop" disabled>стоп</button>
</section> </section>
</main> </main>
</body> </body>

@ -122,11 +122,9 @@ async function connect(deviceId, btn, category, card) {
} }
// --- Переход на экран упражнения --- // --- Переход на экран упражнения ---
startBtn.addEventListener("click", async () => { startBtn.addEventListener("click", () => {
connectScreen.hidden = true; connectScreen.hidden = true;
workoutScreen.hidden = false; workoutScreen.hidden = false;
sizeGraph();
await loadWorkouts();
}); });
// --- Телеметрия --- // --- Телеметрия ---
@ -152,21 +150,9 @@ listen("battery", (event) => {
listen("telemetry", (event) => { listen("telemetry", (event) => {
const t = event.payload.telemetry; const t = event.payload.telemetry;
if (t.Power) { if (t.Power) setMetric("power-value", t.Power.watts);
actualWatts = t.Power.watts; else if (t.Cadence) setMetric("cadence-value", Math.round(t.Cadence.rpm));
setMetric("power-value", `${actualWatts} W`); else if (t.HeartRate) setMetric("hr-value", t.HeartRate.bpm);
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();
}
}); });
function applyStatus(sel, category, status) { function applyStatus(sel, category, status) {
@ -196,318 +182,62 @@ listen("connection", (event) => {
applyStatus(sel, category, status); applyStatus(sel, category, status);
}); });
// --- Воркаут --- // --- Таймер ---
const timerEl = document.querySelector("#timer"); const timerEl = document.querySelector("#timer");
const workoutSelect = document.querySelector("#workout-select"); const timerBtn = document.querySelector("#timer-btn");
const ftpInput = document.querySelector("#ftp-input"); let running = false;
const workoutMeta = document.querySelector("#workout-meta"); let startTs = 0;
const workoutName = document.querySelector("#workout-name"); let elapsed = 0;
const workoutStep = document.querySelector("#workout-step"); let raf = 0;
const workoutBar = document.querySelector("#workout-bar");
const workoutStartBtn = document.querySelector("#workout-start"); function formatTime(ms) {
const workoutPauseBtn = document.querySelector("#workout-pause"); const sec = Math.floor(ms / 1000);
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, unit) {
targetEl.textContent = target != null ? `${target} ${unit}` : "—";
if (actual == null || target == null) {
metricEl.classList.remove("metric--off");
warnEl.textContent = "";
return;
}
const diff = actual - target;
if (Math.abs(diff) > tolerance) {
metricEl.classList.add("metric--off");
} else {
metricEl.classList.remove("metric--off");
}
}
function updatePowerDeviation() {
updateDeviation(powerMetric, powerTarget, powerWarn, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0), "W");
}
function updateCadenceDeviation() {
updateDeviation(cadenceMetric, cadenceTarget, cadenceWarn, actualCadence, targetCadence, CADENCE_TOL_ABS, "rpm");
}
function clearTargets() {
targetWatts = null;
targetCadence = null;
updatePowerDeviation();
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;
function formatClock(sec) {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60); const s = sec % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
} }
async function loadWorkouts() { function tick() {
try { elapsed = performance.now() - startTs;
workouts = await invoke("list_workouts"); timerEl.textContent = formatTime(elapsed);
workoutSelect.replaceChildren(); if (running) raf = requestAnimationFrame(tick);
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);
}
}
function selectedWorkout() {
return workouts.find((w) => w.path === workoutSelect.value);
} }
workoutSelect.addEventListener("change", () => { timerBtn.addEventListener("click", async () => {
const w = selectedWorkout(); running = !running;
workoutName.textContent = w ? w.name : "—"; if (running) {
workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; startTs = performance.now() - elapsed;
}); timerBtn.textContent = "стоп";
tick();
async function setRunning(active) { try {
workoutStartBtn.disabled = active; await invoke("trainer_start");
workoutPauseBtn.disabled = !active; } catch (e) {
workoutStopBtn.disabled = !active; console.error(e);
workoutSelect.disabled = active; }
ftpInput.disabled = active; } else {
if (!active) { cancelAnimationFrame(raf);
workoutPaused = false; timerBtn.textContent = "старт";
workoutPauseBtn.textContent = "пауза"; try {
workoutStep.textContent = "—"; await invoke("trainer_stop");
workoutBar.style.width = "0%"; } catch (e) {
timerEl.textContent = "00:00"; console.error(e);
clearTargets(); }
}
}
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);
} }
}); });
workoutStopBtn.addEventListener("click", async () => { // --- Целевая мощность/каденс ---
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;
try { try {
await invoke("stop_workout"); await invoke("set_target_power", { watts });
await invoke("set_target_cadence", { rpm });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
await setRunning(false);
});
listen("workout-progress", (event) => {
const p = event.payload;
workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`;
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)}%`;
});
listen("workout-finished", () => {
setRunning(false);
}); });

@ -316,55 +316,20 @@ body {
} }
.metric__value { .metric__value {
font-size: var(--text-xl); font-size: var(--text-3xl);
font-weight: var(--fw-semibold); font-weight: var(--fw-semibold);
line-height: 1; line-height: 1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.metric__unit { .metric--cadence .metric__value,
font-size: var(--text-sm); .metric--hr .metric__value {
color: var(--c-text-muted); font-size: var(--text-xl);
}
/* целевое значение — главное */
.metric__target {
font-size: var(--text-2xl);
font-weight: var(--fw-semibold);
line-height: 1;
font-variant-numeric: tabular-nums;
} }
/* фактическое значение — вторичное, краснеет при отклонении */ .metric__unit {
.metric__actual { font-size: var(--text-sm);
font-size: var(--text-base);
color: var(--c-text-muted); color: var(--c-text-muted);
font-variant-numeric: tabular-nums;
}
.metric--off .metric__actual {
color: var(--c-danger);
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 {
display: block;
width: 100%;
height: 140px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
} }
/* --- Управление --- */ /* --- Управление --- */
@ -402,65 +367,3 @@ body {
.field__input:focus { .field__input:focus {
border-color: var(--c-text); 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__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;
}

@ -25,7 +25,6 @@
--c-text-faint: hsl(0 0% 40%); /* едва заметный */ --c-text-faint: hsl(0 0% 40%); /* едва заметный */
--c-accent: hsl(0 0% 93%); /* акцент (инверсия) */ --c-accent: hsl(0 0% 93%); /* акцент (инверсия) */
--c-accent-contrast: hsl(0 0% 4%); --c-accent-contrast: hsl(0 0% 4%);
--c-danger: hsl(0 72% 58%); /* отклонение от цели / ошибка */
/* --- Отступы (база 4px) --- */ /* --- Отступы (база 4px) --- */
--space-1: 0.25rem; /* 4px */ --space-1: 0.25rem; /* 4px */

Loading…
Cancel
Save