feat(webapp): rest timer in sets header
Shows mm:ss since the last set was added for the current exercise. Purely client-side — no round trip to the server. Resets on new exercise, clears when no current exercise or 0 sets, and survives draft restore. The settings-toggle gate is still TBD (Profile/settings feature isn't built yet); the timer is small and muted enough to keep always-on in the meantime. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f36912febe
commit
dabceeeb18
3 changed files with 81 additions and 1 deletions
|
|
@ -78,6 +78,7 @@ function saveDraft() {
|
||||||
editingWorkoutId,
|
editingWorkoutId,
|
||||||
activeView: document.querySelector(".tab.active")?.dataset.view || "log",
|
activeView: document.querySelector(".tab.active")?.dataset.view || "log",
|
||||||
rawDetailsOpen: document.getElementById("raw-details")?.open || false,
|
rawDetailsOpen: document.getElementById("raw-details")?.open || false,
|
||||||
|
lastSetAt,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(draftKey(), JSON.stringify(draft));
|
localStorage.setItem(draftKey(), JSON.stringify(draft));
|
||||||
|
|
@ -156,6 +157,11 @@ function restoreDraft() {
|
||||||
if (details) details.open = true;
|
if (details) details.open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume rest timer if it was running
|
||||||
|
if (currentExercise && getCurrentSets().length > 0 && draft.lastSetAt) {
|
||||||
|
resumeRestTimer(draft.lastSetAt);
|
||||||
|
}
|
||||||
|
|
||||||
syncEditorUI();
|
syncEditorUI();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -195,6 +201,55 @@ let workout = [];
|
||||||
let knownExercises = [];
|
let knownExercises = [];
|
||||||
let currentExercise = null;
|
let currentExercise = null;
|
||||||
let editingWorkoutId = null; // non-null when editing a saved workout
|
let editingWorkoutId = null; // non-null when editing a saved workout
|
||||||
|
let lastSetAt = null; // ms-epoch of most recent addSet, or null
|
||||||
|
let restTimerInterval = null;
|
||||||
|
|
||||||
|
// ── Rest timer ──────────────────────────────────────────────────
|
||||||
|
function _fmtRest(ms) {
|
||||||
|
const total = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRestTimer() {
|
||||||
|
const el = document.getElementById("rest-timer");
|
||||||
|
if (!el) return;
|
||||||
|
const setCount = setsList ? setsList.querySelectorAll(".set-entry").length : 0;
|
||||||
|
if (lastSetAt === null || !currentExercise || setCount === 0) {
|
||||||
|
el.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove("hidden");
|
||||||
|
el.textContent = _fmtRest(Date.now() - lastSetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRestTimer() {
|
||||||
|
lastSetAt = Date.now();
|
||||||
|
if (!restTimerInterval) {
|
||||||
|
restTimerInterval = setInterval(updateRestTimer, 1000);
|
||||||
|
}
|
||||||
|
updateRestTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRestTimer() {
|
||||||
|
lastSetAt = null;
|
||||||
|
if (restTimerInterval) {
|
||||||
|
clearInterval(restTimerInterval);
|
||||||
|
restTimerInterval = null;
|
||||||
|
}
|
||||||
|
updateRestTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeRestTimer(ts) {
|
||||||
|
// Called during draft restore when lastSetAt was persisted.
|
||||||
|
if (typeof ts !== "number" || !Number.isFinite(ts)) return;
|
||||||
|
lastSetAt = ts;
|
||||||
|
if (!restTimerInterval) {
|
||||||
|
restTimerInterval = setInterval(updateRestTimer, 1000);
|
||||||
|
}
|
||||||
|
updateRestTimer();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Structured Log View ─────────────────────────────────────────
|
// ── Structured Log View ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -292,6 +347,7 @@ function startExercise(name) {
|
||||||
weightInput.value = "";
|
weightInput.value = "";
|
||||||
repsInput.focus();
|
repsInput.focus();
|
||||||
notesSection.classList.remove("hidden");
|
notesSection.classList.remove("hidden");
|
||||||
|
stopRestTimer();
|
||||||
syncEditorUI();
|
syncEditorUI();
|
||||||
tg.HapticFeedback.selectionChanged();
|
tg.HapticFeedback.selectionChanged();
|
||||||
saveDraft();
|
saveDraft();
|
||||||
|
|
@ -322,6 +378,7 @@ function addSetToDOM(reps, weight) {
|
||||||
entry.querySelector(".btn-remove").addEventListener("click", () => {
|
entry.querySelector(".btn-remove").addEventListener("click", () => {
|
||||||
entry.remove();
|
entry.remove();
|
||||||
syncEditorUI();
|
syncEditorUI();
|
||||||
|
updateRestTimer();
|
||||||
tg.HapticFeedback.selectionChanged();
|
tg.HapticFeedback.selectionChanged();
|
||||||
saveDraft();
|
saveDraft();
|
||||||
});
|
});
|
||||||
|
|
@ -347,6 +404,7 @@ function addSet() {
|
||||||
|
|
||||||
addSetToDOM(reps, weight);
|
addSetToDOM(reps, weight);
|
||||||
syncEditorUI();
|
syncEditorUI();
|
||||||
|
startRestTimer();
|
||||||
|
|
||||||
logEvent("set.add", {
|
logEvent("set.add", {
|
||||||
exercise: currentExercise?.name || null,
|
exercise: currentExercise?.name || null,
|
||||||
|
|
@ -390,6 +448,7 @@ btnDeleteExercise.addEventListener("click", () => {
|
||||||
currentExercise = null;
|
currentExercise = null;
|
||||||
setsSection.classList.add("hidden");
|
setsSection.classList.add("hidden");
|
||||||
setsList.innerHTML = "";
|
setsList.innerHTML = "";
|
||||||
|
stopRestTimer();
|
||||||
renderWorkout();
|
renderWorkout();
|
||||||
tg.HapticFeedback.notificationOccurred("warning");
|
tg.HapticFeedback.notificationOccurred("warning");
|
||||||
saveDraft();
|
saveDraft();
|
||||||
|
|
@ -413,6 +472,7 @@ function finishCurrentExercise() {
|
||||||
currentExercise = null;
|
currentExercise = null;
|
||||||
setsSection.classList.add("hidden");
|
setsSection.classList.add("hidden");
|
||||||
setsList.innerHTML = "";
|
setsList.innerHTML = "";
|
||||||
|
stopRestTimer();
|
||||||
renderWorkout();
|
renderWorkout();
|
||||||
saveDraft();
|
saveDraft();
|
||||||
}
|
}
|
||||||
|
|
@ -498,6 +558,7 @@ function editExercise(idx) {
|
||||||
repsInput.value = "";
|
repsInput.value = "";
|
||||||
repsInput.focus();
|
repsInput.focus();
|
||||||
|
|
||||||
|
stopRestTimer();
|
||||||
renderWorkout();
|
renderWorkout();
|
||||||
tg.HapticFeedback.selectionChanged();
|
tg.HapticFeedback.selectionChanged();
|
||||||
saveDraft();
|
saveDraft();
|
||||||
|
|
@ -514,6 +575,7 @@ function editSavedWorkout(workoutData) {
|
||||||
currentExercise = null;
|
currentExercise = null;
|
||||||
setsList.innerHTML = "";
|
setsList.innerHTML = "";
|
||||||
setsSection.classList.add("hidden");
|
setsSection.classList.add("hidden");
|
||||||
|
stopRestTimer();
|
||||||
|
|
||||||
// Load all exercises from the saved workout
|
// Load all exercises from the saved workout
|
||||||
(workoutData.superset_groups || []).forEach((group) => {
|
(workoutData.superset_groups || []).forEach((group) => {
|
||||||
|
|
@ -565,6 +627,7 @@ function cancelEdit() {
|
||||||
noteInput.value = "";
|
noteInput.value = "";
|
||||||
setsList.innerHTML = "";
|
setsList.innerHTML = "";
|
||||||
setsSection.classList.add("hidden");
|
setsSection.classList.add("hidden");
|
||||||
|
stopRestTimer();
|
||||||
renderWorkout();
|
renderWorkout();
|
||||||
updateEditingUI();
|
updateEditingUI();
|
||||||
clearDraft();
|
clearDraft();
|
||||||
|
|
@ -610,6 +673,7 @@ btnSaveWorkout.addEventListener("click", async () => {
|
||||||
currentExercise = null;
|
currentExercise = null;
|
||||||
editingWorkoutId = null;
|
editingWorkoutId = null;
|
||||||
noteInput.value = "";
|
noteInput.value = "";
|
||||||
|
stopRestTimer();
|
||||||
renderWorkout();
|
renderWorkout();
|
||||||
updateEditingUI();
|
updateEditingUI();
|
||||||
clearDraft();
|
clearDraft();
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@
|
||||||
<!-- Current exercise card (visible after name is entered) -->
|
<!-- Current exercise card (visible after name is entered) -->
|
||||||
<div id="sets-section" class="card sets-section hidden">
|
<div id="sets-section" class="card sets-section hidden">
|
||||||
<div class="sets-header">
|
<div class="sets-header">
|
||||||
<div class="section-label" id="sets-label">Sets</div>
|
<div class="sets-header-left">
|
||||||
|
<div class="section-label" id="sets-label">Sets</div>
|
||||||
|
<span id="rest-timer" class="rest-timer hidden" aria-label="Time since last set">00:00</span>
|
||||||
|
</div>
|
||||||
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
|
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sets-list"></div>
|
<div id="sets-list"></div>
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,19 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sets-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest-timer {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tg-theme-hint-color, #999);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
color: #e53935 !important;
|
color: #e53935 !important;
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue