|
|
|
|
@ -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)}%`;
|
|
|
|
|
|