feat: workout selection and progress UI (phase 5)

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

@ -38,9 +38,21 @@
<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>
<button class="btn btn--ghost" id="timer-btn">старт</button> <div class="workout-name" id="workout-name">—</div>
</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"> <div class="metric metric--power">
<span class="metric__label">мощность</span> <span class="metric__label">мощность</span>
@ -59,16 +71,20 @@
</div> </div>
</section> </section>
<section class="controls"> <section class="workout-progress">
<label class="field"> <div class="workout-progress__head">
<span class="field__label">целевая мощность</span> <span class="workout-progress__step" id="workout-step">—</span>
<input class="field__input" id="power-input" type="number" value="200" min="0" /> <span class="workout-progress__target" id="workout-target"></span>
</label> </div>
<label class="field"> <div class="workout-progress__track">
<span class="field__label">целевой каденс</span> <div class="workout-progress__bar" id="workout-bar"></div>
<input class="field__input" id="cadence-input" type="number" value="90" min="0" /> </div>
</label> </section>
<button class="btn btn--primary" id="apply-btn">применить</button>
<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,9 +122,10 @@ async function connect(deviceId, btn, category, card) {
} }
// --- Переход на экран упражнения --- // --- Переход на экран упражнения ---
startBtn.addEventListener("click", () => { startBtn.addEventListener("click", async () => {
connectScreen.hidden = true; connectScreen.hidden = true;
workoutScreen.hidden = false; workoutScreen.hidden = false;
await loadWorkouts();
}); });
// --- Телеметрия --- // --- Телеметрия ---
@ -182,62 +183,113 @@ listen("connection", (event) => {
applyStatus(sel, category, status); applyStatus(sel, category, status);
}); });
// --- Таймер --- // --- Воркаут ---
const timerEl = document.querySelector("#timer"); const timerEl = document.querySelector("#timer");
const timerBtn = document.querySelector("#timer-btn"); const workoutSelect = document.querySelector("#workout-select");
let running = false; const ftpInput = document.querySelector("#ftp-input");
let startTs = 0; const workoutMeta = document.querySelector("#workout-meta");
let elapsed = 0; const workoutName = document.querySelector("#workout-name");
let raf = 0; const workoutStep = document.querySelector("#workout-step");
const workoutTarget = document.querySelector("#workout-target");
function formatTime(ms) { const workoutBar = document.querySelector("#workout-bar");
const sec = Math.floor(ms / 1000); const workoutStartBtn = document.querySelector("#workout-start");
const workoutPauseBtn = document.querySelector("#workout-pause");
const workoutStopBtn = document.querySelector("#workout-stop");
let workouts = [];
let workoutPaused = false;
function formatClock(sec) {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
const s = sec % 60; const s = Math.floor(sec % 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
} }
function tick() { async function loadWorkouts() {
elapsed = performance.now() - startTs; try {
timerEl.textContent = formatTime(elapsed); workouts = await invoke("list_workouts");
if (running) raf = requestAnimationFrame(tick); workoutSelect.replaceChildren();
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);
} }
timerBtn.addEventListener("click", async () => { workoutSelect.addEventListener("change", () => {
running = !running; const w = selectedWorkout();
if (running) { workoutName.textContent = w ? w.name : "—";
startTs = performance.now() - elapsed; workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : "";
timerBtn.textContent = "стоп"; });
tick();
async function setRunning(active) {
workoutStartBtn.disabled = active;
workoutPauseBtn.disabled = !active;
workoutStopBtn.disabled = !active;
workoutSelect.disabled = active;
ftpInput.disabled = active;
if (!active) {
workoutPaused = false;
workoutPauseBtn.textContent = "пауза";
workoutStep.textContent = "—";
workoutTarget.textContent = "";
workoutBar.style.width = "0%";
timerEl.textContent = "00:00";
}
}
workoutStartBtn.addEventListener("click", async () => {
const w = selectedWorkout();
const ftp = Number(ftpInput.value);
if (!w || !Number.isFinite(ftp) || ftp <= 0) return;
try { try {
await invoke("trainer_start"); await invoke("set_ftp", { ftp });
await invoke("start_workout", { path: w.path });
await setRunning(true);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} else { });
cancelAnimationFrame(raf);
timerBtn.textContent = "старт"; workoutPauseBtn.addEventListener("click", async () => {
workoutPaused = !workoutPaused;
workoutPauseBtn.textContent = workoutPaused ? "продолжить" : "пауза";
try { try {
await invoke("trainer_stop"); await invoke("pause_workout", { paused: workoutPaused });
} catch (e) { } catch (e) {
console.error(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("set_target_power", { watts }); await invoke("stop_workout");
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}`;
workoutTarget.textContent = p.target_watts != null ? `${p.target_watts} W` : "";
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);
}); });

@ -367,3 +367,70 @@ 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__target {
color: var(--c-text-muted);
font-variant-numeric: tabular-nums;
}
.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;
}

Loading…
Cancel
Save