/** * Circuit (循环) training timer — a finite state machine driving * work / rest intervals across multiple sets. * * idle → working(set 1) → resting → working(set 2) → resting → … → done * * Distinct from utils/timer.js (which counts a single continuous hold up to * a goal and then keeps going until the user stops). A circuit instead has * discrete sets with rest gaps, so it needs its own FSM. * * `workTotal` (effective held seconds, EXCLUDING rest) is what we persist as * the record's `duration` — same meaning as Timer.stop()'s return value — so * the leaderboard / stats aggregation needs zero changes. * * Mirrors utils/timer.js: 1s interval driven by Date.now() (drift-free), * plus a wx.onAppShow hook so the UI snaps to the real elapsed time when the * mini-program returns from background (WeChat freezes setInterval there). */ class CircuitTimer { constructor(options = {}) { this.holdPerSet = Math.max(1, parseInt(options.holdPerSet) || 30) this.sets = Math.max(1, parseInt(options.sets) || 1) this.restPerSet = Math.max(0, parseInt(options.restPerSet) || 0) this.onTick = options.onTick || (() => {}) this.onPhaseChange = options.onPhaseChange || (() => {}) this.onComplete = options.onComplete || (() => {}) this._intervalId = null this._running = false this._paused = false this._appShowBound = false this.reset() this._onAppShow = () => { if (this._running && !this._paused) this._tick() } } reset() { this.phase = 'idle' // idle | working | resting | done this.setIndex = 0 // 1-based current set (0 before start) this.phaseRemaining = this.holdPerSet this.phaseElapsed = 0 this.workTotal = 0 // effective held seconds (excludes rest) this._completedSets = 0 this._phaseStart = 0 } start() { if (this.phase === 'idle') { this.phase = 'working' this.setIndex = 1 this.phaseRemaining = this.holdPerSet this.phaseElapsed = 0 this._completedSets = 0 this._emitPhaseChange() } this._running = true this._paused = false this._phaseStart = Date.now() - this.phaseElapsed * 1000 this._tick() this._intervalId = setInterval(() => this._tick(), 1000) this._bindAppShow() } pause() { if (!this._running || this._paused) return this._paused = true clearInterval(this._intervalId) this._intervalId = null } resume() { if (!this._running || !this._paused) return this._paused = false this._phaseStart = Date.now() - this.phaseElapsed * 1000 this._tick() this._intervalId = setInterval(() => this._tick(), 1000) } stop() { this._running = false this._paused = false clearInterval(this._intervalId) this._intervalId = null this._unbindAppShow() // Effective held seconds — same role as Timer.stop()'s `elapsed`. return this.workTotal } _tick() { if (!this._running || this._paused) return const cap = this.phase === 'working' ? this.holdPerSet : this.restPerSet const elapsed = Math.floor((Date.now() - this._phaseStart) / 1000) this.phaseElapsed = elapsed this.phaseRemaining = Math.max(0, cap - elapsed) if (this.phase === 'working') { // Total held seconds = fully-completed sets + current set progress. this.workTotal = this._completedSets * this.holdPerSet + elapsed } this._emitTick() if (this.phaseRemaining <= 0) { this._advance() } } _advance() { if (this.phase === 'working') { this._completedSets += 1 if (this._completedSets >= this.sets) { // All sets done. this.phase = 'done' this.setIndex = this.sets this._running = false clearInterval(this._intervalId) this._intervalId = null this._unbindAppShow() this.onComplete({ workTotal: this.workTotal, sets: this.sets, holdPerSet: this.holdPerSet }) return } // Move to rest (if any) or straight into the next set. if (this.restPerSet > 0) { this.phase = 'resting' } else { this.phase = 'working' this.setIndex += 1 } this.phaseElapsed = 0 this.phaseRemaining = this.phase === 'resting' ? this.restPerSet : this.holdPerSet this._phaseStart = Date.now() this._emitPhaseChange() this._emitTick() return } if (this.phase === 'resting') { this.phase = 'working' this.setIndex += 1 this.phaseElapsed = 0 this.phaseRemaining = this.holdPerSet this._phaseStart = Date.now() this._emitPhaseChange() this._emitTick() } } _emitTick() { this.onTick({ phase: this.phase, setIndex: this.setIndex, sets: this.sets, phaseRemaining: this.phaseRemaining, phaseElapsed: this.phaseElapsed, holdPerSet: this.holdPerSet, restPerSet: this.restPerSet, workTotal: this.workTotal }) } _emitPhaseChange() { this.onPhaseChange({ phase: this.phase, setIndex: this.setIndex, sets: this.sets, isLastSet: this.setIndex >= this.sets }) } _bindAppShow() { if (this._appShowBound || typeof wx.onAppShow !== 'function') return try { wx.onAppShow(this._onAppShow) this._appShowBound = true } catch (e) {} } _unbindAppShow() { if (!this._appShowBound || typeof wx.offAppShow !== 'function') return try { wx.offAppShow(this._onAppShow) } catch (e) {} this._appShowBound = false } } module.exports = CircuitTimer