/** * INTERACTIVE TIMER APP * Core Logic - Handles: * - Landing Clock * - Timer Modes (Time-watch, Countdown, Stopwatch, Lap, Relay, Custom) * - Display Formats * - Lap, Relay Split & Custom Activity Logic * - Sound Alerts & Session Management * - Dark Mode & UI Layout */ const app = { // STATE currentView: 'landing', currentMode: null, isRunning: false, isPaused: false, isPreStarting: false, isDarkMode: false, startTime: 0, elapsedTime: 0, pausedTime: 0, animationId: null, history: [], // Universal storage for laps, splits, activities countdownStartValue: 0, // Relay State currentParticipant: 1, participantNames: [], relayParticipantStartTime: 0, relayParticipantElapsed: 0, relayColors: ['#3b82f6', '#10b981', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1', '#a855f7', '#d946ef'], // Custom State customActivities: [ { name: 'Activity 1', duration: 60000, isRest: false } ], currentActivityIndex: 0, // Session titles stored per mode sessionTitles: { 'time-watch': '', 'countdown': '', 'stopwatch': '', 'lap': '', 'relay': '', 'custom': '' }, // Audio Helpers audioCtx: null, lastPlayedSecond: -1, // CONFIG modes: { 'time-watch': { title: 'Time-watch', allowPause: true, hasLaps: false, canToggleCountdown: true }, 'countdown': { title: 'Countdown Timer', allowPause: true, hasLaps: false, forceCountdown: true, canSave: true }, 'stopwatch': { title: 'Stopwatch', allowPause: false, hasLaps: false, canToggleCountdown: false, canSave: true }, 'lap': { title: 'Lap Timer', allowPause: false, hasLaps: true, canToggleCountdown: false }, 'relay': { title: 'Relay Timer', allowPause: false, hasRelay: true, canToggleCountdown: false }, 'custom': { title: 'Custom Timer', allowPause: true, isCustom: true, canToggleCountdown: true, canSave: true } }, // ELEMENTS el: { landingClock: document.getElementById('landing-clock'), mainTimer: document.getElementById('main-timer'), subTimer: document.getElementById('sub-timer'), relayParticipantTimer: document.getElementById('relay-participant-timer'), viewLanding: document.getElementById('view-landing'), viewTimer: document.getElementById('view-timer'), timerTitle: document.getElementById('timer-title'), activeActivityName: document.getElementById('active-activity-name'), sessionTitle: document.getElementById('session-title'), timerSideColumn: document.getElementById('timer-side-column'), historySection: document.getElementById('history-section'), btnStart: document.getElementById('btn-start'), btnPause: document.getElementById('btn-pause'), btnStop: document.getElementById('btn-stop'), btnReset: document.getElementById('btn-reset'), btnLap: document.getElementById('btn-lap'), btnNext: document.getElementById('btn-next'), btnSaveMain: document.getElementById('btn-save-main'), btnSaveBuilder: document.getElementById('btn-save-session-builder'), btnResetBuilder: document.getElementById('btn-reset-builder'), formatSelect: document.getElementById('format-select'), listTitle: document.getElementById('list-title'), listHead: document.getElementById('list-head'), listBody: document.getElementById('list-body'), lapSort: document.getElementById('lap-sort'), lapResultsContainer: document.getElementById('lap-results-container'), countdownInputs: document.getElementById('countdown-inputs'), inputH: document.getElementById('input-h'), inputM: document.getElementById('input-m'), inputS: document.getElementById('input-s'), relayConfig: document.getElementById('relay-config'), relayParticipantCountBox: document.getElementById('relay-participant-count-box'), participantCount: document.getElementById('participant-count'), participantNamesContainer: document.getElementById('participant-names-container'), relayActiveParticipantContainer: document.getElementById('relay-active-participant-container'), relayActiveName: document.getElementById('relay-active-name'), btnRelaySplit: document.getElementById('btn-relay-split'), customBuilder: document.getElementById('custom-builder'), activityList: document.getElementById('activity-list'), customTotalDuration: document.getElementById('custom-total-duration'), savedTimersContainer: document.getElementById('saved-timers-container'), savedTimersDropdown: document.getElementById('saved-timers-dropdown'), btnDeleteSaved: document.getElementById('btn-delete-saved'), displayModeContainer: document.getElementById('display-mode-container'), toggleDisplayMode: document.getElementById('toggle-display-mode'), // Settings / Options optionsDropdown: document.getElementById('optionsDropdown'), optCountdownContainer: document.getElementById('opt-countdown-container'), customOptions: document.getElementById('custom-options'), restAlertContainer: document.getElementById('rest-alert-container'), settingIsCountdown: document.getElementById('setting-is-countdown'), settingRestDuration: document.getElementById('setting-rest-duration'), alertRestPreEnd: document.getElementById('alert-rest-pre-end'), alertPreStart: document.getElementById('alert-pre-start'), alertPreStartSec: document.getElementById('alert-pre-start-seconds'), alertPreEnd: document.getElementById('alert-pre-end'), alertPreEndSec: document.getElementById('alert-pre-end-seconds'), alertCompletion: document.getElementById('alert-completion'), preFinishSection: document.getElementById('pre-finish-section'), brandLink: document.getElementById('brand-link'), darkModeToggle: document.getElementById('dark-mode-toggle') }, init() { console.log('Timer App Initializing...'); this.initDarkMode(); this.shuffleRelayColors(); this.updateLandingClock(); this.bindEvents(); this.renderCustomBuilder(); this.resetTimer(); this.loadSavedTimers(); this.bindKeyboardShortcuts(); }, shuffleRelayColors() { for (let i = this.relayColors.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [this.relayColors[i], this.relayColors[j]] = [this.relayColors[j], this.relayColors[i]]; } }, bindKeyboardShortcuts() { document.addEventListener('keydown', (e) => { if (this.currentView !== 'timer' || !this.currentMode) return; // Do not trigger if user is typing in an input or textarea if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) { // If it's the Escape key, we still want to stop the timer even if focused on input if (e.key !== 'Escape') return; } if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); if (this.currentMode === 'lap') { if (!this.isRunning || this.isPaused) { this.handleStartClick(); } else { this.recordLap(); } } else { if (!this.isRunning || this.isPaused) { this.handleStartClick(); } else { this.pauseTimer(); } } } else if (e.key === 'Escape' || e.code === 'Escape') { e.preventDefault(); this.stopTimer(); } }); }, initDarkMode() { this.isDarkMode = localStorage.getItem('darkMode') === 'true'; this.applyDarkMode(); }, applyDarkMode() { document.body.classList.toggle('dark-mode', this.isDarkMode); document.getElementById('moon-icon').classList.toggle('d-none', this.isDarkMode); document.getElementById('sun-icon').classList.toggle('d-none', !this.isDarkMode); }, toggleDarkMode() { this.isDarkMode = !this.isDarkMode; localStorage.setItem('darkMode', this.isDarkMode); this.applyDarkMode(); }, bindEvents() { this.el.btnStart.addEventListener('click', () => this.handleStartClick()); this.el.btnPause.addEventListener('click', () => this.pauseTimer()); this.el.btnStop.addEventListener('click', () => this.stopTimer()); this.el.btnReset.addEventListener('click', () => this.resetTimer()); this.el.btnLap.addEventListener('click', () => this.recordLap()); this.el.btnNext.addEventListener('click', () => this.recordRelaySplit()); this.el.btnRelaySplit.addEventListener('click', () => this.recordRelaySplit()); this.el.btnSaveMain.addEventListener('click', () => this.saveCurrentTimer()); this.el.btnSaveBuilder.addEventListener('click', () => this.saveCurrentTimer()); if (this.el.btnResetBuilder) { this.el.btnResetBuilder.addEventListener('click', () => this.resetCustomBuilder()); } this.el.brandLink.addEventListener('click', (e) => { e.preventDefault(); this.switchView('landing'); }); this.el.darkModeToggle.addEventListener('click', () => this.toggleDarkMode()); this.el.formatSelect.addEventListener('change', () => this.updateDisplay()); this.el.settingIsCountdown.addEventListener('change', () => this.resetTimer()); this.el.lapSort.addEventListener('change', () => this.renderHistory()); this.el.participantCount.addEventListener('input', () => this.renderParticipantInputs()); this.el.savedTimersDropdown.addEventListener('change', (e) => { if (e.target.value) { this.loadTimer(e.target.value); } }); this.el.btnDeleteSaved.addEventListener('click', () => { const id = this.el.savedTimersDropdown.value; if (id) { this.deleteTimer(id); } else { alert('Please select a session to delete.'); } }); this.el.sessionTitle.addEventListener('input', (e) => { if (this.currentMode) { this.sessionTitles[this.currentMode] = e.target.value; } }); this.el.toggleDisplayMode.addEventListener('change', () => { const label = this.el.displayModeContainer.querySelector('label'); if (this.el.toggleDisplayMode.checked) { label.textContent = 'Timer Progress'; } else { label.textContent = 'Activity Progress'; } this.updateDisplay(); }); }, updateLandingClock() { const now = new Date(); const h = String(now.getHours()).padStart(2, '0'); const m = String(now.getMinutes()).padStart(2, '0'); const s = String(now.getSeconds()).padStart(2, '0'); if (this.el.landingClock) { this.el.landingClock.textContent = `${h}:${m}:${s}`; } setTimeout(() => this.updateLandingClock(), 1000); }, switchView(viewName) { this.currentView = viewName; document.querySelectorAll('.view-section').forEach(s => s.classList.remove('active')); if (viewName === 'landing') { this.el.viewLanding.classList.add('active'); this.stopTimer(); } else { this.el.viewTimer.classList.add('active'); } }, setMode(modeKey) { this.currentMode = modeKey; const mode = this.modes[modeKey]; this.el.timerTitle.textContent = mode.title; this.switchView('timer'); this.resetTimer(); // Update session title for the specific mode this.el.sessionTitle.value = this.sessionTitles[modeKey] || ''; // UI Adjustments this.el.btnPause.classList.toggle('d-none', !mode.allowPause); this.el.btnLap.classList.toggle('d-none', !mode.hasLaps); this.el.btnNext.classList.add('d-none'); // Removed from timer entirely for all modes // Update Save button visibility this.updateSaveButtonVisibility(); this.el.timerSideColumn.classList.toggle('d-none', !mode.hasLaps && !mode.hasRelay && !mode.isCustom); if (mode.isCustom) { this.el.customBuilder.classList.remove('d-none'); this.el.historySection.classList.add('d-none'); this.el.savedTimersContainer.classList.remove('d-none'); this.el.relayConfig.classList.add('d-none'); this.el.displayModeContainer.classList.remove('d-none'); // Set initial label based on toggle state const label = this.el.displayModeContainer.querySelector('label'); label.textContent = this.el.toggleDisplayMode.checked ? 'Timer Progress' : 'Activity Progress'; } else if (mode.hasRelay) { this.el.customBuilder.classList.add('d-none'); this.el.relayConfig.classList.remove('d-none'); this.el.historySection.classList.remove('d-none'); this.el.savedTimersContainer.classList.add('d-none'); this.el.displayModeContainer.classList.add('d-none'); // Move history to main column immediately for Relay (separate from participants) this.el.lapResultsContainer.classList.remove('d-none'); this.el.lapResultsContainer.appendChild(this.el.historySection); this.el.lapSort.classList.remove('d-none'); } else { this.el.customBuilder.classList.add('d-none'); this.el.relayConfig.classList.add('d-none'); this.el.historySection.classList.remove('d-none'); this.el.savedTimersContainer.classList.toggle('d-none', !mode.canSave); this.el.displayModeContainer.classList.add('d-none'); } this.el.countdownInputs.classList.toggle('d-none', modeKey !== 'countdown'); this.el.customOptions.classList.toggle('d-none', !mode.isCustom); this.el.restAlertContainer.classList.toggle('d-none', !mode.isCustom); this.el.activeActivityName.classList.add('d-none'); this.el.relayActiveParticipantContainer.classList.add('d-none'); this.el.relayParticipantTimer.classList.add('d-none'); // Options Dropdown visibility logic this.el.optionsDropdown.classList.toggle('d-none', modeKey === 'time-watch'); // Pre-finish section visibility logic const noPreFinishModes = ['stopwatch', 'lap', 'relay']; this.el.preFinishSection.classList.toggle('d-none', noPreFinishModes.includes(modeKey)); this.el.lapSort.classList.add('d-none'); // Options Box adjustments this.el.optCountdownContainer.classList.toggle('d-none', !mode.canToggleCountdown && !mode.forceCountdown); if (mode.forceCountdown) { this.el.settingIsCountdown.checked = true; this.el.settingIsCountdown.disabled = true; } else if (modeKey === 'custom') { this.el.settingIsCountdown.checked = false; // Default to count up this.el.settingIsCountdown.disabled = false; } else { this.el.settingIsCountdown.checked = false; this.el.settingIsCountdown.disabled = false; } // Update List Header if (mode.hasLaps) { this.el.listTitle.textContent = 'Laps Recorded'; this.el.listHead.innerHTML = 'LapSplit TimeDeltaNotes'; } else if (mode.hasRelay) { this.el.listTitle.textContent = 'Relay Splits'; this.el.listHead.innerHTML = 'ParticipantStart TimeSplit TimeTotal Time'; this.renderParticipantInputs(); } else if (mode.isCustom) { this.el.listTitle.textContent = 'Activities'; this.el.listHead.innerHTML = 'ActivityDurationStatus'; } this.loadSavedTimers(); this.updateDisplay(); }, updateSaveButtonVisibility() { const mode = this.modes[this.currentMode]; if (!mode) return; const hasBeenStarted = this.elapsedTime > 0 || this.isRunning || this.isPaused; const canSave = mode.canSave && !mode.isCustom; // Save button appears after start and either paused or stopped const shouldShowSave = canSave && hasBeenStarted && (this.isPaused || !this.isRunning); this.el.btnSaveMain.classList.toggle('d-none', !shouldShowSave); }, renderParticipantInputs() { const count = Math.min(12, Math.max(1, parseInt(this.el.participantCount.value) || 1)); this.el.participantCount.value = count; let html = ''; for (let i = 1; i <= count; i++) { const defaultColor = this.relayColors[(i - 1) % this.relayColors.length]; html += `
#${i}
`; } this.el.participantNamesContainer.innerHTML = html; }, handleStartClick() { if (!this.el.sessionTitle.value.trim()) { if (this.currentMode === 'custom') { alert('Please enter a Session Name or Title before starting.'); this.el.sessionTitle.focus(); return; } else { // Default to current day and time const now = new Date(); this.el.sessionTitle.value = now.toLocaleString(); if (this.currentMode) { this.sessionTitles[this.currentMode] = this.el.sessionTitle.value; } } } if (this.isRunning && !this.isPaused) return; // Sound Pre-start check if (!this.isPaused && this.el.alertPreStart.checked) { const preStartSec = parseInt(this.el.alertPreStartSec.value) || 0; if (preStartSec > 0) { this.runPreStartCountdown(preStartSec); return; } } this.startTimer(); }, runPreStartCountdown(seconds) { this.isPreStarting = true; this.el.btnStart.disabled = true; this.el.btnReset.classList.remove("d-none"); this.el.btnReset.disabled = false; this.el.sessionTitle.disabled = true; let remaining = seconds; this.el.mainTimer.textContent = `STARTING IN ${remaining}...`; this.beep(440, 0.1); const interval = setInterval(() => { remaining--; if (remaining > 0) { this.el.mainTimer.textContent = `STARTING IN ${remaining}...`; this.beep(440, 0.1); } else { clearInterval(interval); this.isPreStarting = false; this.beep(880, 0.3); this.startTimer(); } }, 1000); }, startTimer() { const mode = this.modes[this.currentMode]; const now = performance.now(); const isCountdownMode = this.el.settingIsCountdown.checked; if (this.currentMode === 'countdown' && !this.isPaused) { const h = parseInt(this.el.inputH.value) || 0; const m = parseInt(this.el.inputM.value) || 0; const s = parseInt(this.el.inputS.value) || 0; const totalMs = ((h * 3600) + (m * 60) + s) * 1000; if (totalMs <= 0) { alert('Please enter a duration.'); return; } this.countdownStartValue = totalMs; this.elapsedTime = totalMs; this.el.countdownInputs.classList.add('d-none'); } if (mode.isCustom && !this.isPaused) { this.currentActivityIndex = 0; this.history = []; this.el.activeActivityName.classList.remove('d-none'); this.el.activeActivityName.textContent = this.customActivities[0].name; this.countdownStartValue = this.customActivities[0].duration; this.elapsedTime = isCountdownMode ? this.countdownStartValue : 0; // Transition: Builder -> History this.el.customBuilder.classList.add('d-none'); this.el.historySection.classList.remove('d-none'); this.el.savedTimersContainer.classList.add('d-none'); // Add first activity as "in-progress" this.updateCurrentActivityStatus('in-progress'); } else if (mode.isCustom && this.isPaused) { this.updateCurrentActivityStatus('in-progress'); } if (mode.hasRelay && !this.isPaused) { this.currentParticipant = 1; this.history = []; this.el.relayActiveParticipantContainer.classList.remove('d-none'); this.el.relayParticipantCountBox.classList.add('d-none'); this.updateRelayActiveDisplay(); this.relayParticipantStartTime = now; this.relayParticipantElapsed = 0; this.el.relayParticipantTimer.classList.add('d-none'); } if (this.isPaused) { const offset = (isCountdownMode) ? (this.countdownStartValue - this.elapsedTime) : this.elapsedTime; this.startTime = now - offset; if (mode.hasRelay) { this.relayParticipantStartTime = now - this.relayParticipantElapsed; } } else { this.startTime = now; if (mode.hasRelay) { this.relayParticipantStartTime = now; } } this.isRunning = true; this.isPaused = false; this.lastPlayedSecond = -1; this.el.btnStart.disabled = true; this.el.btnReset.classList.remove("d-none"); this.el.btnPause.disabled = false; this.el.btnStop.disabled = false; this.el.btnReset.disabled = false; this.el.sessionTitle.disabled = true; this.el.mainTimer.classList.remove('timer-finish'); this.el.mainTimer.classList.remove('timer-finish-theme'); this.el.mainTimer.classList.remove('timer-alert'); // Lock inputs if (this.el.inputH) this.el.inputH.disabled = true; if (this.el.inputM) this.el.inputM.disabled = true; if (this.el.inputS) this.el.inputS.disabled = true; if (this.el.participantCount) this.el.participantCount.disabled = true; document.querySelectorAll('.participant-name-input').forEach(i => i.disabled = true); document.querySelectorAll('.participant-color-input').forEach(i => i.disabled = true); // Update UI this.updateSaveButtonVisibility(); this.tick(); }, pauseTimer() { if (!this.isRunning || this.isPaused) return; const mode = this.modes[this.currentMode]; if (mode && !mode.allowPause) return; this.isPaused = true; this.pausedTime = this.elapsedTime; cancelAnimationFrame(this.animationId); this.el.btnStart.disabled = false; this.el.btnPause.disabled = true; this.el.btnReset.disabled = false; if (this.currentMode === 'custom') { this.updateCurrentActivityStatus('paused'); } // Update UI this.updateSaveButtonVisibility(); }, stopTimer() { if (!this.isRunning && !this.isPaused) return; const wasRunning = this.isRunning; this.isRunning = false; this.isPaused = false; cancelAnimationFrame(this.animationId); this.el.btnStart.disabled = false; this.el.btnPause.disabled = true; this.el.btnStop.disabled = true; this.el.btnReset.disabled = false; this.el.mainTimer.classList.remove('timer-alert'); this.el.relayActiveParticipantContainer.classList.add('d-none'); this.el.relayParticipantTimer.classList.add('d-none'); if (this.currentMode === 'countdown') { this.el.countdownInputs.classList.remove('d-none'); } if (this.currentMode === 'lap') { this.recordLap(); // Place list under timer this.el.lapResultsContainer.classList.remove('d-none'); this.el.lapResultsContainer.appendChild(this.el.historySection); this.el.lapSort.classList.remove('d-none'); } else if (this.currentMode === 'relay') { if (wasRunning) this.recordRelaySplit(true); // Relay splits panel is already under controls in setMode, just ensure sorting is visible this.el.lapResultsContainer.classList.remove('d-none'); this.el.lapSort.classList.remove('d-none'); } else if (this.currentMode === 'custom' && wasRunning) { this.updateCurrentActivityStatus('Canceled'); } // Update UI this.updateSaveButtonVisibility(); }, resetTimer() { this.stopTimer(); this.elapsedTime = 0; this.pausedTime = 0; this.countdownStartValue = 0; this.history = []; this.currentParticipant = 1; this.currentActivityIndex = 0; this.lastPlayedSecond = -1; this.relayParticipantStartTime = 0; this.relayParticipantElapsed = 0; this.el.listBody.innerHTML = ''; this.el.mainTimer.classList.remove('timer-finish'); this.el.mainTimer.classList.remove('timer-finish-theme'); this.el.mainTimer.classList.remove('timer-alert'); this.el.btnReset.disabled = false; this.el.btnStart.disabled = false; this.el.btnPause.disabled = true; this.el.btnStop.disabled = true; this.el.btnNext.disabled = false; this.el.btnRelaySplit.disabled = false; this.el.activeActivityName.classList.add('d-none'); this.el.relayActiveParticipantContainer.classList.add('d-none'); this.el.relayParticipantTimer.classList.add('d-none'); this.el.relayParticipantCountBox.classList.remove('d-none'); this.el.sessionTitle.disabled = false; if (this.el.inputH) { this.el.inputH.disabled = false; if (this.currentMode === 'countdown') this.el.inputH.value = ''; } if (this.el.inputM) { this.el.inputM.disabled = false; if (this.currentMode === 'countdown') this.el.inputM.value = ''; } if (this.el.inputS) { this.el.inputS.disabled = false; if (this.currentMode === 'countdown') this.el.inputS.value = ''; } if (this.currentMode === 'countdown' || this.currentMode === 'stopwatch') { this.el.savedTimersDropdown.value = ''; this.el.sessionTitle.value = ''; if (this.currentMode) this.sessionTitles[this.currentMode] = ''; } if (this.el.participantCount) this.el.participantCount.disabled = false; document.querySelectorAll('.participant-name-input').forEach(i => i.disabled = false); document.querySelectorAll('.participant-color-input').forEach(i => i.disabled = false); if (this.currentMode === 'countdown') { this.el.countdownInputs.classList.remove('d-none'); } // Move history back to side column default if (this.el.timerSideColumn.querySelector('.card-precise')) { this.el.timerSideColumn.querySelector('.card-precise').appendChild(this.el.historySection); } this.el.lapResultsContainer.classList.add('d-none'); this.el.lapSort.classList.add('d-none'); // Transition: History -> Builder (if custom) if (this.currentMode === 'custom') { this.el.customBuilder.classList.remove('d-none'); this.el.historySection.classList.add('d-none'); this.el.savedTimersContainer.classList.remove('d-none'); } else if (this.currentMode === 'relay') { // Put it back under timer for relay even after reset this.el.lapResultsContainer.classList.remove('d-none'); this.el.lapResultsContainer.appendChild(this.el.historySection); this.el.lapSort.classList.remove('d-none'); } // Update UI this.updateSaveButtonVisibility(); this.updateDisplay(); }, resetCustomBuilder() { this.customActivities = [{ name: 'Activity 1', duration: 60000, isRest: false }]; this.el.sessionTitle.value = ''; if (this.currentMode) this.sessionTitles[this.currentMode] = ''; this.el.savedTimersDropdown.value = ''; this.renderCustomBuilder(); this.resetTimer(); }, tick() { if (!this.isRunning || this.isPaused) return; const mode = this.modes[this.currentMode]; const now = performance.now(); const isCountdownMode = this.el.settingIsCountdown.checked; if (isCountdownMode) { const delta = now - this.startTime; this.elapsedTime = Math.max(0, this.countdownStartValue - delta); this.checkAlerts(this.elapsedTime); if (this.elapsedTime === 0) { if (mode.isCustom) { this.nextActivity(); } else { this.updateDisplay(); this.stopTimer(); this.playCompletionAlert(); return; } } } else if (mode.isCustom) { // Count up custom mode const delta = now - this.startTime; this.elapsedTime = delta; this.checkAlerts(this.countdownStartValue - this.elapsedTime); if (this.elapsedTime >= this.countdownStartValue) { this.nextActivity(); } } else { // Standard count up this.elapsedTime = now - this.startTime; if (mode.hasRelay) { this.relayParticipantElapsed = now - this.relayParticipantStartTime; } } this.updateDisplay(); this.animationId = requestAnimationFrame(() => this.tick()); }, nextActivity() { const isCountdownMode = this.el.settingIsCountdown.checked; // Mark current as completed this.updateCurrentActivityStatus('completed'); this.currentActivityIndex++; if (this.currentActivityIndex < this.customActivities.length) { const nextAct = this.customActivities[this.currentActivityIndex]; this.el.activeActivityName.textContent = nextAct.name; this.countdownStartValue = nextAct.duration; this.elapsedTime = isCountdownMode ? this.countdownStartValue : 0; this.startTime = performance.now(); this.lastPlayedSecond = -1; this.beep(660, 0.2); this.updateCurrentActivityStatus('in-progress'); } else { this.updateDisplay(); this.stopTimer(); this.playCompletionAlert(); } }, updateCurrentActivityStatus(status) { const act = this.customActivities[this.currentActivityIndex]; if (!act) return; const existingIdx = this.history.findIndex(h => h.index === this.currentActivityIndex); if (existingIdx !== -1) { this.history[existingIdx].status = status; } else { this.history.push({ index: this.currentActivityIndex, id: act.name, delta: act.duration, isRest: act.isRest, status: status }); } this.renderHistory(); }, checkAlerts(ms) { const currentAct = this.customActivities[this.currentActivityIndex]; const isRest = currentAct && currentAct.isRest; const enabled = isRest ? this.el.alertRestPreEnd.checked : this.el.alertPreEnd.checked; if (!enabled) { this.el.mainTimer.classList.remove('timer-alert'); return; } const secondsLeft = Math.ceil(ms / 1000); const warningSec = parseInt(this.el.alertPreEndSec.value) || 0; if (secondsLeft <= warningSec && secondsLeft > 0) { this.el.mainTimer.classList.add('timer-alert'); if (secondsLeft !== this.lastPlayedSecond) { this.lastPlayedSecond = secondsLeft; this.beep(isRest ? 330 : 523, 0.1); } } else { this.el.mainTimer.classList.remove('timer-alert'); } }, updateDisplay() { const ms = this.elapsedTime; let format = this.el.formatSelect.value; if (this.currentMode === 'custom' && this.el.toggleDisplayMode.checked) { const totalMs = this.getTotalProgress(); this.el.mainTimer.textContent = this.formatTime(totalMs, format); } else { this.el.mainTimer.textContent = this.formatTime(ms, format); } this.el.subTimer.classList.add('d-none'); if (this.currentMode === 'relay' && !this.el.relayParticipantTimer.classList.contains('d-none')) { this.el.relayParticipantTimer.textContent = this.formatTime(this.relayParticipantElapsed, 'hh:mm:ss.ms'); } }, getTotalProgress() { let completedDuration = 0; for (let i = 0; i < this.currentActivityIndex; i++) { completedDuration += this.customActivities[i].duration; } let currentElapsed = 0; if (this.el.settingIsCountdown.checked) { currentElapsed = Math.max(0, this.countdownStartValue - this.elapsedTime); } else { currentElapsed = this.elapsedTime; } return completedDuration + currentElapsed; }, formatTime(ms, format) { let absMs = Math.abs(ms); let seconds = Math.floor(absMs / 1000); let minutes = Math.floor(seconds / 60); let hours = Math.floor(minutes / 60); seconds %= 60; minutes %= 60; const pad = (n, l = 2) => String(n).padStart(l, '0'); switch (format) { case 'hh:mm:ss.ms': return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(Math.floor((absMs % 1000) / 10), 2)}`; case 'hh:mm:ss': return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; case 'hours': return (ms / 3600000).toFixed(4) + ' h'; case 'minutes': return (ms / 60000).toFixed(2) + ' m'; case 'seconds': return (ms / 1000).toFixed(0) + ' s'; case 'seconds.ms': return (ms / 1000).toFixed(2) + ' s'; default: return '00:00:00'; } }, parseTime(str) { const parts = str.split(':').reverse(); let ms = 0; if (parts[0]) ms += (parseFloat(parts[0]) || 0) * 1000; // seconds if (parts[1]) ms += (parseInt(parts[1]) || 0) * 60000; // minutes if (parts[2]) ms += (parseInt(parts[2]) || 0) * 3600000; // hours return ms; }, recordLap() { const now = this.elapsedTime; const lastTime = this.history.length > 0 ? this.history[this.history.length - 1].time : 0; const delta = now - lastTime; this.history.push({ id: this.history.length + 1, time: now, delta: delta }); this.renderHistory(); }, updateRelayActiveDisplay() { const maxParticipants = parseInt(this.el.participantCount.value) || 1; if (this.currentParticipant > maxParticipants) { this.el.relayActiveParticipantContainer.classList.add('d-none'); this.el.relayParticipantTimer.classList.add('d-none'); return; } const nameInput = document.querySelector(`.participant-name-input[data-index="${this.currentParticipant}"]`); const name = nameInput ? (nameInput.value.trim() || `P${this.currentParticipant}`) : `P${this.currentParticipant}`; const colorInput = document.querySelector(`.participant-color-input[data-index="${this.currentParticipant}"]`); const color = colorInput ? colorInput.value : '#3b82f6'; this.el.relayActiveName.textContent = name; this.el.relayActiveName.style.color = color; this.el.btnRelaySplit.disabled = false; this.el.btnRelaySplit.textContent = 'Split'; }, recordRelaySplit(isFinal = false) { const now = this.elapsedTime; const lastTime = this.history.length > 0 ? this.history[this.history.length - 1].time : 0; const delta = now - lastTime; const maxParticipants = parseInt(this.el.participantCount.value) || 1; if (this.currentParticipant > maxParticipants && !isFinal) return; // Get name and color const nameInput = document.querySelector(`.participant-name-input[data-index="${this.currentParticipant}"]`); let name = nameInput ? (nameInput.value.trim() || `P${this.currentParticipant}`) : `P${this.currentParticipant}`; const colorInput = document.querySelector(`.participant-color-input[data-index="${this.currentParticipant}"]`); const color = colorInput ? colorInput.value : '#3b82f6'; // Show secondary timer after first split if (this.history.length >= 0) { this.el.relayParticipantTimer.classList.remove('d-none'); } // If it's the last participant, mark as final but keep their name if (this.currentParticipant === maxParticipants) { isFinal = true; } this.history.push({ id: name, color: color, startTime: lastTime, time: now, delta: delta, status: isFinal ? 'DONE' : 'SPLIT' }); this.el.btnRelaySplit.disabled = true; if (!isFinal) { this.currentParticipant++; this.updateRelayActiveDisplay(); this.relayParticipantStartTime = performance.now(); this.relayParticipantElapsed = 0; } else { this.stopTimer(); this.el.btnRelaySplit.disabled = true; } this.renderHistory(); }, renderHistory() { let sortedHistory = this.history.slice(); const sortVal = this.el.lapSort.value; if (this.currentMode === 'lap' || this.currentMode === 'relay') { if (sortVal === 'best') { sortedHistory.sort((a, b) => a.delta - b.delta); } else if (sortVal === 'worse') { sortedHistory.sort((a, b) => b.delta - a.delta); } else { sortedHistory.reverse(); } let minDelta = Infinity; this.history.forEach(h => { if (h.delta < minDelta) minDelta = h.delta; }); if (this.currentMode === 'relay') { this.el.listBody.innerHTML = sortedHistory.map(h => ` ${h.id} ${this.formatTime(h.startTime || 0, 'hh:mm:ss')} ${this.formatTime(h.delta, 'hh:mm:ss')} ${this.formatTime(h.time, 'hh:mm:ss')} `).join(''); } else { this.el.listBody.innerHTML = sortedHistory.map(h => ` ${h.id} ${this.formatTime(h.time, 'hh:mm:ss')} +${this.formatTime(h.delta, 'seconds')} ${h.delta === minDelta && h.delta > 0 ? 'BEST' : ''} `).join(''); } } else if (this.currentMode === 'custom') { this.el.listBody.innerHTML = this.history.slice().reverse().map(h => { let badgeClass = 'bg-success'; if (h.status === 'in-progress') badgeClass = 'bg-primary'; if (h.status === 'paused') badgeClass = 'bg-warning text-dark'; if (h.status === 'Canceled') badgeClass = 'bg-danger'; return ` ${h.id} ${this.formatTime(h.delta, 'hh:mm:ss')} ${h.status} `; }).join(''); } }, // Custom Activity Builder renderCustomBuilder() { let totalMs = 0; this.el.activityList.innerHTML = this.customActivities.map((act, i) => { totalMs += act.duration; const isFirst = i === 0; return `
${act.isRest ? `
Rest
` : `
`}
`}).join(''); if (this.el.customTotalDuration) { this.el.customTotalDuration.textContent = `Total: ${this.formatTime(totalMs, 'hh:mm:ss')}`; } }, addActivity() { this.customActivities.push({ name: `Activity ${this.customActivities.length + 1}`, duration: 60000, isRest: false }); this.renderCustomBuilder(); }, addActivityAfter(index) { this.customActivities.splice(index + 1, 0, { name: `Activity ${this.customActivities.length + 1}`, duration: 60000, isRest: false }); this.renderCustomBuilder(); }, addRestAfter(index) { const restDuration = (parseInt(this.el.settingRestDuration.value) || 5) * 1000; this.customActivities.splice(index + 1, 0, { name: 'Rest', duration: restDuration, isRest: true }); this.renderCustomBuilder(); }, duplicateActivity(index) { const original = this.customActivities[index]; const clone = { ...original }; this.customActivities.splice(index + 1, 0, clone); this.renderCustomBuilder(); }, removeActivity(index) { if (this.customActivities.length <= 1) return; this.customActivities.splice(index, 1); this.renderCustomBuilder(); }, updateActName(index, val) { this.customActivities[index].name = val; }, updateActDuration(index, val) { if (val.includes(':')) { this.customActivities[index].duration = this.parseTime(val); } else { this.customActivities[index].duration = (parseFloat(val) || 0) * 1000; } this.renderCustomBuilder(); }, applyUnit(index, unit) { const input = document.getElementById(`act-input-${index}`); const val = input.value; let ms = 0; if (val.includes(':')) { ms = this.parseTime(val); // If they clicked a unit button, maybe they want to multiply the currently parsed value? // E.g. if it's "00:01:00" and they click "M", it becomes 60 minutes. let seconds = ms / 1000; switch(unit) { case 'H': ms = seconds * 3600000; break; case 'M': ms = seconds * 60000; break; case 'S': ms = seconds * 1000; break; } } else { let num = parseFloat(val) || 0; switch(unit) { case 'H': ms = num * 3600000; break; case 'M': ms = num * 60000; break; case 'S': ms = num * 1000; break; } } this.customActivities[index].duration = ms; this.renderCustomBuilder(); }, // Saved Timers Logic async saveCurrentTimer() { const name = this.el.sessionTitle.value.trim(); if (!name) { alert('Please enter a session name to save.'); this.el.sessionTitle.focus(); return; } const config = { mode: this.currentMode, isCountdown: this.el.settingIsCountdown.checked, format: this.el.formatSelect.value }; if (this.currentMode === 'custom') { config.activities = this.customActivities; } else if (this.currentMode === 'countdown') { config.duration = { h: this.el.inputH.value, m: this.el.inputM.value, s: this.el.inputS.value }; } try { const resp = await fetch('api/timers.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, config }) }); const result = await resp.json(); if (result.success) { this.loadSavedTimers(); alert('Session saved successfully!'); if (this.currentMode === 'custom') { this.resetCustomBuilder(); } } else { alert('Error saving session: ' + result.error); } } catch (e) { console.error(e); alert('Failed to connect to server.'); } }, async loadSavedTimers() { try { const resp = await fetch('api/timers.php'); const result = await resp.json(); if (result.success) { this.renderSavedTimers(result.data); } } catch (e) { console.error('Failed to load saved timers:', e); } }, renderSavedTimers(timers) { if (!timers || timers.length === 0) { this.el.savedTimersDropdown.innerHTML = ''; return; } let html = ''; timers.forEach(t => { const config = JSON.parse(t.config); // Show custom timers even if they don't have a mode yet (backwards compat) if (config.mode === this.currentMode || (this.currentMode === 'custom' && !config.mode)) { const durationStr = this.getDurationStringFromConfig(config); const displayLabel = durationStr ? `${t.name} (${durationStr})` : t.name; html += ``; } }); this.el.savedTimersDropdown.innerHTML = html; }, getDurationStringFromConfig(config) { let totalMs = 0; if (config.mode === 'custom' || (!config.mode && this.currentMode === 'custom')) { if (config.activities) { config.activities.forEach(a => totalMs += (a.duration || 0)); } } else if (config.mode === 'countdown' && config.duration) { const h = parseInt(config.duration.h) || 0; const m = parseInt(config.duration.m) || 0; const s = parseInt(config.duration.s) || 0; totalMs = ((h * 3600) + (m * 60) + s) * 1000; } else { return null; } if (totalMs <= 0) return null; return this.formatTime(totalMs, 'hh:mm:ss'); }, async loadTimer(id) { try { const resp = await fetch('api/timers.php'); const result = await resp.json(); if (result.success) { const timer = result.data.find(t => t.id == id); if (timer) { const config = JSON.parse(timer.config); this.el.sessionTitle.value = timer.name; if (this.currentMode) this.sessionTitles[this.currentMode] = timer.name; this.el.settingIsCountdown.checked = !!config.isCountdown; this.el.formatSelect.value = config.format || 'hh:mm:ss'; if (config.mode === 'custom' || (!config.mode && this.currentMode === 'custom')) { this.customActivities = config.activities || []; this.renderCustomBuilder(); } else if (config.mode === 'countdown' && config.duration) { this.el.inputH.value = config.duration.h || ''; this.el.inputM.value = config.duration.m || ''; this.el.inputS.value = config.duration.s || ''; } this.resetTimer(); } } } catch (e) { console.error(e); } }, async deleteTimer(id) { if (!confirm('Are you sure you want to delete this saved session?')) return; try { const resp = await fetch(`api/timers.php?id=${id}`, { method: 'DELETE' }); const result = await resp.json(); if (result.success) { this.loadSavedTimers(); alert('Session deleted.'); } else { alert('Error deleting: ' + result.error); } } catch (e) { console.error(e); } }, playCompletionAlert() { this.el.mainTimer.classList.add('timer-finish-theme'); setTimeout(() => { this.el.mainTimer.classList.remove('timer-finish-theme'); }, 3000); if (this.el.alertCompletion.checked) { this.beep(880, 0.5); setTimeout(() => this.beep(1100, 0.5), 200); } }, beep(freq = 440, duration = 0.1) { try { if (!this.audioCtx) { this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (this.audioCtx.state === 'suspended') { this.audioCtx.resume(); } const osc = this.audioCtx.createOscillator(); const gain = this.audioCtx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, this.audioCtx.currentTime); gain.gain.setValueAtTime(0.1, this.audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + duration); osc.connect(gain); gain.connect(this.audioCtx.destination); osc.start(); osc.stop(this.audioCtx.currentTime + duration); } catch (e) { console.warn('Audio error:', e); } } }; // Start the app document.addEventListener('DOMContentLoaded', () => app.init());