/** * 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, // Custom State customActivities: [ { name: 'Activity 1', duration: 60000, isRest: false }, { name: 'Rest', duration: 30000, isRest: true } ], currentActivityIndex: 0, draggedItemIndex: null, // 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 }, 'stopwatch': { title: 'Stopwatch', allowPause: false, hasLaps: false, canToggleCountdown: false }, 'lap': { title: 'Lap Timer', allowPause: false, hasLaps: true, canToggleCountdown: false }, 'relay': { title: 'Relay Timer', allowPause: true, hasRelay: true, canToggleCountdown: false }, 'custom': { title: 'Custom Timer', allowPause: true, isCustom: true, canToggleCountdown: true } }, // ELEMENTS el: { landingClock: document.getElementById('landing-clock'), mainTimer: document.getElementById('main-timer'), subTimer: document.getElementById('sub-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'), formatSelect: document.getElementById('format-select'), listTitle: document.getElementById('list-title'), listHead: document.getElementById('list-head'), listBody: document.getElementById('list-body'), 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'), participantCount: document.getElementById('participant-count'), customBuilder: document.getElementById('custom-builder'), activityList: document.getElementById('activity-list'), btnAddActivity: document.getElementById('btn-add-activity'), // Settings / Options 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'), brandLink: document.getElementById('brand-link'), darkModeToggle: document.getElementById('dark-mode-toggle') }, init() { console.log('Timer App Initializing...'); this.initDarkMode(); this.updateLandingClock(); this.bindEvents(); this.renderCustomBuilder(); this.resetTimer(); }, 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.btnAddActivity.addEventListener('click', () => this.addActivity()); 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()); }, 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(); // UI Adjustments this.el.btnPause.classList.toggle('d-none', !mode.allowPause); this.el.btnLap.classList.toggle('d-none', !mode.hasLaps); this.el.btnNext.classList.toggle('d-none', !mode.hasRelay); 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'); } else { this.el.customBuilder.classList.add('d-none'); this.el.historySection.classList.remove('d-none'); } this.el.countdownInputs.classList.toggle('d-none', modeKey !== 'countdown'); this.el.relayConfig.classList.toggle('d-none', !mode.hasRelay); 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'); // 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 = 'ParticipantTotal TimeSplitStatus'; } else if (mode.isCustom) { this.el.listTitle.textContent = 'Activities Completed'; this.el.listHead.innerHTML = 'ActivityDurationStatus'; } this.updateDisplay(); }, handleStartClick() { if (!this.el.sessionTitle.value.trim()) { alert('Please enter a Session Name or Title before starting.'); this.el.sessionTitle.focus(); return; } 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.disabled = true; 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; } if (mode.isCustom && !this.isPaused) { this.currentActivityIndex = 0; this.history = []; this.renderHistory(); 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'); } if (this.isPaused) { const offset = (isCountdownMode) ? (this.countdownStartValue - this.elapsedTime) : this.elapsedTime; this.startTime = now - offset; } else { this.startTime = now; } this.isRunning = true; this.isPaused = false; this.lastPlayedSecond = -1; this.el.btnStart.disabled = true; this.el.btnPause.disabled = false; this.el.btnStop.disabled = false; this.el.btnReset.disabled = true; this.el.sessionTitle.disabled = true; this.el.mainTimer.classList.remove('timer-finish'); // 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; this.tick(); }, pauseTimer() { if (!this.isRunning || this.isPaused) 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; }, stopTimer() { if (!this.isRunning && !this.isPaused) return; 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; if (this.currentMode === 'lap') { this.recordLap(); } else if (this.currentMode === 'relay') { this.recordRelaySplit(true); } }, resetTimer() { this.stopTimer(); this.elapsedTime = 0; this.pausedTime = 0; this.countdownStartValue = 0; this.history = []; this.currentParticipant = 1; this.currentActivityIndex = 0; this.lastPlayedSecond = -1; this.el.listBody.innerHTML = ''; this.el.mainTimer.classList.remove('timer-finish'); this.el.btnReset.disabled = true; this.el.btnStart.disabled = false; this.el.btnPause.disabled = true; this.el.btnStop.disabled = true; this.el.activeActivityName.classList.add('d-none'); this.el.sessionTitle.disabled = false; if (this.el.inputH) this.el.inputH.disabled = false; if (this.el.inputM) this.el.inputM.disabled = false; if (this.el.inputS) this.el.inputS.disabled = false; if (this.el.participantCount) this.el.participantCount.disabled = false; // Transition: History -> Builder (if custom) if (this.currentMode === 'custom') { this.el.customBuilder.classList.remove('d-none'); this.el.historySection.classList.add('d-none'); } this.updateDisplay(); }, 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; } this.updateDisplay(); this.animationId = requestAnimationFrame(() => this.tick()); }, nextActivity() { const isCountdownMode = this.el.settingIsCountdown.checked; this.recordCustomActivity(); 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); } else { this.updateDisplay(); this.stopTimer(); this.playCompletionAlert(); } }, 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) return; const secondsLeft = Math.ceil(ms / 1000); const warningSec = parseInt(this.el.alertPreEndSec.value) || 0; if (secondsLeft <= warningSec && secondsLeft > 0 && secondsLeft !== this.lastPlayedSecond) { this.lastPlayedSecond = secondsLeft; this.beep(isRest ? 330 : 523, 0.1); } }, updateDisplay() { const ms = this.elapsedTime; const format = this.el.formatSelect.value; this.el.mainTimer.textContent = this.formatTime(ms, format); if (format !== 'hh:mm:ss.ms') { this.el.subTimer.textContent = this.formatTime(ms, 'hh:mm:ss.ms'); this.el.subTimer.classList.remove('d-none'); } else { this.el.subTimer.classList.add('d-none'); } }, 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); let milliseconds = Math.floor(absMs % 1000); 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(milliseconds, 3)}`; 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(3) + ' s'; default: return '00:00:00.000'; } }, 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(); }, 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; this.history.push({ id: `P${this.currentParticipant}`, time: now, delta: delta, status: isFinal ? 'DONE' : 'SPLIT' }); if (!isFinal) { this.currentParticipant++; if (this.currentParticipant > maxParticipants) { this.stopTimer(); } } this.renderHistory(); }, recordCustomActivity() { const act = this.customActivities[this.currentActivityIndex]; // Skip rest activities in completed list as requested if (act.isRest) return; this.history.push({ id: act.name, delta: act.duration, status: 'OK' }); this.renderHistory(); }, renderHistory() { if (this.currentMode === 'lap') { let minDelta = Infinity; let bestId = -1; this.history.forEach(h => { if (h.delta < minDelta) { minDelta = h.delta; bestId = h.id; } }); this.el.listBody.innerHTML = this.history.slice().reverse().map(h => ` #${h.id} ${this.formatTime(h.time, 'hh:mm:ss.ms')} +${this.formatTime(h.delta, 'seconds.ms')} ${h.id === bestId ? 'BEST' : ''} `).join(''); } else if (this.currentMode === 'relay') { this.el.listBody.innerHTML = this.history.slice().reverse().map(h => ` ${h.id} ${this.formatTime(h.time, 'hh:mm:ss.ms')} ${this.formatTime(h.delta, 'seconds.ms')} ${h.status} `).join(''); } else if (this.currentMode === 'custom') { this.el.listBody.innerHTML = this.history.slice().reverse().map(h => ` ${h.id} ${this.formatTime(h.delta, 'hh:mm:ss')} ${h.status} `).join(''); } }, // Custom Activity Builder renderCustomBuilder() { this.el.activityList.innerHTML = this.customActivities.map((act, i) => `
${act.isRest ? `
Rest
` : `
`}
`).join(''); }, onDragStart(index, event) { this.draggedItemIndex = index; event.dataTransfer.effectAllowed = 'move'; // Add a small delay to make the actual element transparent while dragging setTimeout(() => { event.target.classList.add('dragging'); }, 0); }, onDragOver(index, event) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }, onDrop(index, event) { event.preventDefault(); if (this.draggedItemIndex === null || this.draggedItemIndex === index) return; const item = this.customActivities.splice(this.draggedItemIndex, 1)[0]; this.customActivities.splice(index, 0, item); this.draggedItemIndex = null; this.renderCustomBuilder(); }, addActivity() { this.customActivities.push({ name: `Activity ${this.customActivities.length + 1}`, duration: 60000, isRest: false }); this.renderCustomBuilder(); }, addRestAfter(index) { const restDuration = (parseInt(this.el.settingRestDuration.value) || 30) * 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; }, applyUnit(index, unit) { const input = document.getElementById(`act-input-${index}`); const val = parseFloat(input.value) || 0; let ms = 0; switch(unit) { case 'H': ms = val * 3600000; break; case 'M': ms = val * 60000; break; case 'S': ms = val * 1000; break; } this.customActivities[index].duration = ms; this.renderCustomBuilder(); }, playCompletionAlert() { this.el.mainTimer.classList.add('timer-finish'); 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());