diff --git a/app.js b/app.js index 343f229..2a26fd5 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,8 @@ class VitruvianApp { constructor() { this.device = new VitruvianDevice(); this.loadHistory = []; + this.frozenHistory = null; + this.isGraphFrozen = false; this.maxHistoryPoints = 300; // 30 seconds at 100ms polling this.maxPosA = 1000; // Dynamic max for Right Cable (A) this.maxPosB = 1000; // Dynamic max for Left Cable (B) @@ -13,9 +15,12 @@ class VitruvianApp { this.targetReps = 0; // Target working reps this.workoutHistory = []; // Track completed workouts this.currentWorkout = null; // Current workout info + this._isStopping = false; + this._isCompleting = false; this.setupLogging(); this.setupGraph(); this.resetRepCountersToEmpty(); + this.updateResumeGraphButton(false); } setupLogging() { @@ -23,6 +28,14 @@ class VitruvianApp { this.device.onLog = (message, type) => { this.addLogEntry(message, type); }; + + this.device.onDisconnect = () => { + this.updateConnectionStatus(false); + this.addLogEntry( + "Device disconnected. Please reconnect before starting another workout.", + "error", + ); + }; } setupGraph() { @@ -65,12 +78,64 @@ class VitruvianApp { this.drawGraph(); } + updateResumeGraphButton(visible) { + const button = document.getElementById("resumeGraphBtn"); + if (!button) return; + + if (visible) { + button.classList.remove("hidden"); + } else { + button.classList.add("hidden"); + } + } + + freezeGraphSnapshot(message) { + if (this.isGraphFrozen) { + return; + } + + if (this.loadHistory.length === 0) { + return; + } + + this.isGraphFrozen = true; + this.frozenHistory = this.loadHistory.map((point) => ({ ...point })); + this.updateResumeGraphButton(true); + + const logMessage = + message || "Graph frozen – click Resume Live Graph to return to live data."; + this.addLogEntry(logMessage, "info"); + + this.drawGraph(); + } + + resumeLiveGraph(silent = false) { + if (!this.isGraphFrozen) { + return; + } + + this.isGraphFrozen = false; + this.frozenHistory = null; + this.loadHistory = []; + this.updateResumeGraphButton(false); + + if (!silent) { + this.addLogEntry("Live graph resumed", "info"); + } + + this.drawGraph(); + } + drawGraph() { if (!this.ctx || !this.canvas) return; const width = this.canvasDisplayWidth || this.canvas.width; const height = this.canvasDisplayHeight || this.canvas.height; const ctx = this.ctx; + const history = + this.isGraphFrozen && this.frozenHistory && this.frozenHistory.length > 0 + ? this.frozenHistory + : this.loadHistory; if (width === 0 || height === 0) return; // Canvas not sized yet @@ -81,7 +146,7 @@ class VitruvianApp { // Set text rendering for crisp fonts ctx.textRendering = "optimizeLegibility"; - if (this.loadHistory.length < 2) { + if (history.length < 2) { // Not enough data to draw ctx.fillStyle = "#6c757d"; ctx.font = "14px -apple-system, sans-serif"; @@ -96,7 +161,7 @@ class VitruvianApp { // Find max load for scaling let maxLoad = 0; - for (const point of this.loadHistory) { + for (const point of history) { const totalLoad = point.loadA + point.loadB; if (totalLoad > maxLoad) maxLoad = totalLoad; } @@ -149,7 +214,7 @@ class VitruvianApp { // Draw lines for each cable and total // Calculate spacing based on actual number of points to fill the graph width - const numPoints = this.loadHistory.length; + const numPoints = history.length; const pointSpacing = numPoints > 1 ? graphWidth / (numPoints - 1) : 0; // Helper to draw a line @@ -161,8 +226,8 @@ class VitruvianApp { ctx.beginPath(); let started = false; - for (let i = 0; i < this.loadHistory.length; i++) { - const point = this.loadHistory[i]; + for (let i = 0; i < history.length; i++) { + const point = history[i]; const value = getData(point); const x = padding + i * pointSpacing; const y = padding + graphHeight - (value / maxLoad) * graphHeight; @@ -284,6 +349,10 @@ class VitruvianApp { document.getElementById("barA").style.height = heightA + "%"; document.getElementById("barB").style.height = heightB + "%"; + if (this.isGraphFrozen) { + return; + } + // Add to load history this.loadHistory.push({ timestamp: sample.timestamp, @@ -383,7 +452,13 @@ class VitruvianApp { } completeWorkout() { - if (this.currentWorkout) { + if (!this.currentWorkout || this._isCompleting) { + return; + } + + this._isCompleting = true; + + try { // Add to history this.addToWorkoutHistory({ mode: this.currentWorkout.mode, @@ -395,6 +470,12 @@ class VitruvianApp { // Reset to empty state this.resetRepCountersToEmpty(); this.addLogEntry("Workout completed and saved to history", "success"); + + this.freezeGraphSnapshot( + "Workout completed – graph frozen. Click Resume Live Graph to review the last set.", + ); + } finally { + this._isCompleting = false; } } @@ -508,16 +589,50 @@ class VitruvianApp { } async stopWorkout() { + if (this._isStopping) { + return; + } + + this._isStopping = true; + const stopBtn = document.getElementById("stopBtn"); + if (stopBtn) { + stopBtn.disabled = true; + } + + const startedAt = Date.now(); + try { await this.device.sendStopCommand(); this.addLogEntry("Workout stopped by user", "info"); // Complete the workout and save to history this.completeWorkout(); + + this.freezeGraphSnapshot( + "STOP acknowledged – graph frozen. Click Resume Live Graph to continue live telemetry.", + ); } catch (error) { console.error("Stop workout error:", error); this.addLogEntry(`Failed to stop workout: ${error.message}`, "error"); + + if (!this.device.isConnected) { + this.updateConnectionStatus(false); + this.addLogEntry( + "Device disconnected during STOP safety fallback. Please reconnect before continuing.", + "error", + ); + } + alert(`Failed to stop workout: ${error.message}`); + } finally { + const duration = Date.now() - startedAt; + this.addLogEntry(`STOP flow completed in ${duration}ms`, "info"); + + if (stopBtn && this.device.isConnected) { + stopBtn.disabled = false; + } + + this._isStopping = false; } } @@ -572,6 +687,8 @@ class VitruvianApp { }; this.updateRepCounters(); + this.resumeLiveGraph(true); + await this.device.startProgram(params); // Set up monitor listener @@ -638,6 +755,8 @@ class VitruvianApp { }; this.updateRepCounters(); + this.resumeLiveGraph(true); + await this.device.startEcho(params); // Set up monitor listener diff --git a/device.js b/device.js index 43c1f84..90a0761 100644 --- a/device.js +++ b/device.js @@ -32,11 +32,13 @@ class VitruvianDevice { this.propertyInterval = null; this.monitorInterval = null; this.onLog = null; // Callback for logging + this.onDisconnect = null; // Callback for external disconnect handling this.propertyListeners = []; this.repListeners = []; this.monitorListeners = []; this.lastGoodPosA = 0; this.lastGoodPosB = 0; + this._isStopping = false; } log(message, type = "info") { @@ -76,6 +78,13 @@ class VitruvianDevice { this.device.addEventListener("gattserverdisconnected", () => { this.log("Device disconnected", "error"); this.handleDisconnect(); + if (typeof this.onDisconnect === "function") { + try { + this.onDisconnect(); + } catch (callbackError) { + console.error("onDisconnect callback error:", callbackError); + } + } }); // Connect to GATT server @@ -225,11 +234,59 @@ class VitruvianDevice { if (!this.isConnected) { throw new Error("Device not connected"); } + if (this._isStopping) { + this.log("STOP already in progress", "info"); + return; + } + + this._isStopping = true; + + try { + this.stopPropertyPolling(); + this.stopMonitorPolling(); - this.log("\nSending STOP command...", "info"); - const cmd = buildInitCommand(); // Stop command is same as init command - await this.writeWithResponse("Stop command", cmd); - this.log("Workout stopped!", "success"); + const cmd = buildInitCommand(); + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + this.log(`\nSTOP attempt ${attempt}/${maxAttempts}...`, "info"); + + try { + await this.writeWithResponse("Stop command", cmd); + this.log("Workout stopped!", "success"); + return; + } catch (error) { + const message = (error && error.message) || String(error); + + if ( + attempt < maxAttempts && + /Network|GATT|InvalidState/i.test(message) + ) { + this.log( + `STOP retry ${attempt + 1}/${maxAttempts} after transient error: ${message}`, + "info", + ); + await this.sleep(100); + continue; + } + + throw error; + } + } + } catch (error) { + this.log(`STOP failed after retries: ${error.message}`, "error"); + try { + await this.disconnect(); + } catch (disconnectError) { + this.log( + `Disconnect during STOP cleanup failed: ${disconnectError.message}`, + "error", + ); + } + throw error; + } finally { + this._isStopping = false; + } } // Start a workout program diff --git a/docs/feedback_tracker.md b/docs/feedback_tracker.md new file mode 100644 index 0000000..3f0ff8c --- /dev/null +++ b/docs/feedback_tracker.md @@ -0,0 +1,18 @@ +# Bug Reports + +# Feature Requests +[ ]: Brainstorm with Ai appropriate tests for this repo and consider adjusting to a Test/Behavior Driven Development approach. +# Ractoring Suggestions + +# Feedback From Reddit Users (r/vitruvian_form) + - User: sudden_Hunter-9342 + Feedback: `trialled it. Connects easily. functions are pretty cool, with echo easy to use. + 1. [x] BUG: Mid-exercise STOP button does not work possible fixes: + a. disconnect and reconnect + b. Choose and start an exercise mode a few times until the error goes away(warm up reps and working reps reset) + Notes: Fixed via PR #1 (STOP retries + UI debouncing). STOP now retries 3× with BLE cleanup; UI reflects fallback disconnect so users no longer need to reconnect manually. + 2. [ ] BUG: Seems the final rep is not executed. (i.e. 8th rep out of 8 deloads). Not a big deal, i just add an extra rep. + 3. [x] FEATURE: Stop the chart graphing at the end of reps. otherwise main data in the chart disappears to the side. + Notes: Addressed in PR #1 by freezing the graph when STOP/auto-complete fires and exposing a Resume button so the last 30s stays visible. + 4. [ ] FEATURE: Adding rest periods/timer + Dev Notes: Addressing in vitruvian-change-plan.md diff --git a/docs/releases/CHANGELOG.md b/docs/releases/CHANGELOG.md new file mode 100644 index 0000000..b8d04f2 --- /dev/null +++ b/docs/releases/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 2025-10-26 +- PR: https://github.com/Allmight97/workoutmachineappfree.github.io/pull/1 +- Title: STOP reliability + Freeze Live Graph after Set (PR1+PR2) +- Summary: + - Hardened STOP behavior with guarded retries and polling pause to ensure immediate, reliable halts. + - Added graph freeze on STOP or set completion with a visible "Resume Live Graph" control; keeps numeric stats live. +- User impact: + - Safer workouts: STOP succeeds promptly or disconnects cleanly; clear log feedback. + - Better review UX: final set data remains visible; simple resume returns to live view. +- Developer impact: + - Structured logs around STOP attempts and acknowledgements aid diagnostics. + - Clear, minimal state flags for graph (`isGraphFrozen`, `frozenHistory`) and explicit resume entry point. +- Files changed: + - app.js – STOP UI debounce/timing; graph freeze/resume helpers; guards in live stats and draw routines. + - device.js – STOP retries (3x/100ms), pause polling during STOP, disconnect fallback, idempotent guard. + - index.html – Added hidden "Resume Live Graph" button next to Load History header. + diff --git a/index.html b/index.html index 8e0f5d7..7e41d19 100644 --- a/index.html +++ b/index.html @@ -678,6 +678,7 @@