From b6ea23f02d640bab57fe2418c72f49bd0bbd94ac Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 17:09:26 +0300 Subject: [PATCH] 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 */