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>
<header class="workout-header">
<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>
<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">
<div class="metric metric--power">
<span class="metric__label">мощность</span>
@ -59,16 +71,20 @@
</div>
</section>
<section class="controls">
<label class="field">
<span class="field__label">целевая мощность</span>
<input class="field__input" id="power-input" type="number" value="200" min="0" />
</label>
<label class="field">
<span class="field__label">целевой каденс</span>
<input class="field__input" id="cadence-input" type="number" value="90" min="0" />
</label>
<button class="btn btn--primary" id="apply-btn">применить</button>
<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>
</div>
</section>
<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>
</main>
</body>

@ -122,9 +122,10 @@ async function connect(deviceId, btn, category, card) {
}
// --- Переход на экран упражнения ---
startBtn.addEventListener("click", () => {
startBtn.addEventListener("click", async () => {
connectScreen.hidden = true;
workoutScreen.hidden = false;
await loadWorkouts();
});
// --- Телеметрия ---
@ -182,62 +183,113 @@ listen("connection", (event) => {
applyStatus(sel, category, status);
});
// --- Таймер ---
// --- Воркаут ---
const timerEl = document.querySelector("#timer");
const timerBtn = document.querySelector("#timer-btn");
let running = false;
let startTs = 0;
let elapsed = 0;
let raf = 0;
function formatTime(ms) {
const sec = Math.floor(ms / 1000);
const workoutSelect = document.querySelector("#workout-select");
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");
let workouts = [];
let workoutPaused = false;
function formatClock(sec) {
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")}`;
}
function tick() {
elapsed = performance.now() - startTs;
timerEl.textContent = formatTime(elapsed);
if (running) raf = requestAnimationFrame(tick);
async function loadWorkouts() {
try {
workouts = await invoke("list_workouts");
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);
}
workoutSelect.addEventListener("change", () => {
const w = selectedWorkout();
workoutName.textContent = w ? w.name : "—";
workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : "";
});
timerBtn.addEventListener("click", async () => {
running = !running;
if (running) {
startTs = performance.now() - elapsed;
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 {
await invoke("trainer_start");
await invoke("set_ftp", { ftp });
await invoke("start_workout", { path: w.path });
await setRunning(true);
} catch (e) {
console.error(e);
}
} else {
cancelAnimationFrame(raf);
timerBtn.textContent = "старт";
});
workoutPauseBtn.addEventListener("click", async () => {
workoutPaused = !workoutPaused;
workoutPauseBtn.textContent = workoutPaused ? "продолжить" : "пауза";
try {
await invoke("trainer_stop");
await invoke("pause_workout", { paused: workoutPaused });
} catch (e) {
console.error(e);
}
}
});
// --- Целевая мощность/каденс ---
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;
workoutStopBtn.addEventListener("click", async () => {
try {
await invoke("set_target_power", { watts });
await invoke("set_target_cadence", { rpm });
await invoke("stop_workout");
} catch (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 {
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