feat: show target power/cadence with off-target warning

pull/4/head
lambda 6 days ago
parent ee913002ff
commit b6ea23f02d
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -54,15 +54,19 @@
</section>
<section class="metrics">
<div class="metric metric--power">
<div class="metric metric--power" id="power-metric">
<span class="metric__label">мощность</span>
<span class="metric__value" id="power-value">—</span>
<span class="metric__unit">W</span>
<span class="metric__target" id="power-target"></span>
<span class="metric__warn" id="power-warn" hidden></span>
</div>
<div class="metric metric--cadence">
<div class="metric metric--cadence" id="cadence-metric">
<span class="metric__label">каденс</span>
<span class="metric__value" id="cadence-value">—</span>
<span class="metric__unit">rpm</span>
<span class="metric__target" id="cadence-target"></span>
<span class="metric__warn" id="cadence-warn" hidden></span>
</div>
<div class="metric metric--hr">
<span class="metric__label">чсс</span>
@ -74,7 +78,6 @@
<section class="workout-progress">
<div class="workout-progress__head">
<span class="workout-progress__step" id="workout-step">—</span>
<span class="workout-progress__target" id="workout-target"></span>
</div>
<div class="workout-progress__track">
<div class="workout-progress__bar" id="workout-bar"></div>

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

@ -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);

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

Loading…
Cancel
Save