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 */