diff --git a/asset/images/counter.svg b/asset/images/counter.svg new file mode 100644 index 0000000..8ca829b --- /dev/null +++ b/asset/images/counter.svg @@ -0,0 +1,3 @@ + + + diff --git a/asset/images/eye-off.svg b/asset/images/eye-off.svg new file mode 100644 index 0000000..fda37f0 --- /dev/null +++ b/asset/images/eye-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/asset/images/eye.svg b/asset/images/eye.svg new file mode 100644 index 0000000..269b96a --- /dev/null +++ b/asset/images/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/asset/javascript/TimeController.js b/asset/javascript/TimeController.js index ad44e3d..797c84a 100644 --- a/asset/javascript/TimeController.js +++ b/asset/javascript/TimeController.js @@ -28,6 +28,12 @@ function TimerController(reference, hooks = {}) { const stopButton = actionButtonsContainer.querySelector('.js-stop-button'); const pauseButton = actionButtonsContainer.querySelector('.js-pause-button'); const editButton = actionButtonsContainer.querySelector('.js-edit-button'); + const counterToggleButton = actionButtonsContainer.querySelector('.js-counter-toggle-button'); + + const counterContainer = reference.querySelector('.js-timer-counter'); + const counterInput = reference.querySelector('.js-counter-value'); + const counterDecrementButton = reference.querySelector('.js-counter-decrement-button'); + const counterIncrementButton = reference.querySelector('.js-counter-increment-button'); const editActionButtonsContainer = reference.querySelector('.js-edit-container-stopwatch'); const cancelEditButton = editActionButtonsContainer.querySelector('.js-cancel-edit-button'); @@ -39,10 +45,13 @@ function TimerController(reference, hooks = {}) { let deadline = null; let previousTimerValue = DEFAULT_SECONDS; let resetTimeoutId = null; + let counterEnabled = false; + let counterValue = 0; function init() { bindInputs(); bindButtons(); + bindCounter(); setRemaining(DEFAULT_SECONDS); } @@ -118,6 +127,55 @@ function TimerController(reference, hooks = {}) { pauseButton.addEventListener('click', pause); } + function bindCounter() { + if (!counterContainer) return; + + if (counterToggleButton) counterToggleButton.addEventListener('click', toggleCounter); + if (counterDecrementButton) counterDecrementButton.addEventListener('click', decrementCounter); + if (counterIncrementButton) counterIncrementButton.addEventListener('click', incrementCounter); + + if (counterInput) { + counterInput.addEventListener('change', () => setCounter(counterInput.value)); + counterInput.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + counterInput.blur(); + } + }); + } + } + + function toggleCounter() { + setCounterEnabled(!counterEnabled); + notifyChange(); + } + + function setCounterEnabled(enabled) { + counterEnabled = !!enabled; + + if (counterContainer) counterContainer.classList.toggle('hide', !counterEnabled); + if (counterToggleButton) counterToggleButton.classList.toggle('is-active', counterEnabled); + reference.classList.toggle('has-counter', counterEnabled); + } + + function setCounter(value) { + const parsed = parseInt(value, 10); + + counterValue = Number.isFinite(parsed) ? parsed : 0; + + if (counterInput) counterInput.value = counterValue; + + notifyChange(); + } + + function incrementCounter() { + setCounter(counterValue + 1); + } + + function decrementCounter() { + setCounter(counterValue - 1); + } + function openEditInput() { if (TimerStatus.isRunning(status) || TimerStatus.isCountdown(status)) return; @@ -344,15 +402,31 @@ function TimerController(reference, hooks = {}) { remaining, status, endsAt: isActive() ? deadline : null, + counter: { + enabled: counterEnabled, + value: counterValue, + }, }; } + function restoreCounter(counter) { + const enabled = !!(counter && counter.enabled); + const parsed = counter ? parseInt(counter.value, 10) : 0; + + counterEnabled = enabled; + counterValue = Number.isFinite(parsed) ? parsed : 0; + + if (counterInput) counterInput.value = counterValue; + setCounterEnabled(counterEnabled); + } + function restore(data = {}) { clearTimeout(resetTimeoutId); setInverted(false); hideCountdownNumber(); setName(data.name || ''); + restoreCounter(data.counter); duration = data.duration > 0 ? data.duration : DEFAULT_SECONDS; const wasActive = data.status === TimerStatus.RUNNING || data.status === TimerStatus.COUNTDOWN; @@ -411,6 +485,13 @@ function TimerController(reference, hooks = {}) { getStatus: () => status, getRemaining: () => remaining, getDuration: () => duration, + getCounter: () => counterValue, + setCounter, + incrementCounter, + decrementCounter, + isCounterEnabled: () => counterEnabled, + setCounterEnabled, + toggleCounter, serialize, restore, destroy, diff --git a/asset/javascript/TimerApp.js b/asset/javascript/TimerApp.js index bbf8d86..0b41d29 100644 --- a/asset/javascript/TimerApp.js +++ b/asset/javascript/TimerApp.js @@ -20,6 +20,8 @@ export function TimerApp(reference) { const stopAllButton = globalControls.querySelector('.js-stop-all-button'); const enterFullscreenButton = globalControls.querySelector('.js-enter-fullscreen-button'); const exitFullscreenButton = globalControls.querySelector('.js-exit-fullscreen-button'); + const hideControlsButton = globalControls.querySelector('.js-hide-controls-button'); + const showControlsButton = globalControls.querySelector('.js-show-controls-button'); const layoutButtons = Array.from(reference.querySelectorAll('.js-layout-button')); const themeMenuContainer = reference.querySelector('.js-theme-menu-container'); @@ -31,6 +33,7 @@ export function TimerApp(reference) { let timers = []; let layout = DEFAULT_LAYOUT; let tickIntervalId = null; + let controlsHidden = false; function init() { bindGlobalButtons(); @@ -48,6 +51,7 @@ export function TimerApp(reference) { if (state && AVAILABLE_LAYOUTS.includes(state.layout)) { theme.setTheme(state.theme); + setControlsHidden(!!state.controlsHidden); applyLayout(state.layout, state.timers || []); return; } @@ -66,6 +70,23 @@ export function TimerApp(reference) { enterFullscreenButton.addEventListener('click', handleFullscreen); exitFullscreenButton.addEventListener('click', handleFullscreen); + + hideControlsButton.addEventListener('click', () => { + setControlsHidden(true); + persist(); + }); + showControlsButton.addEventListener('click', () => { + setControlsHidden(false); + persist(); + }); + } + + function setControlsHidden(hidden) { + controlsHidden = !!hidden; + + reference.classList.toggle('controls-hidden', controlsHidden); + hideControlsButton.classList.toggle('hide', controlsHidden); + showControlsButton.classList.toggle('hide', !controlsHidden); } function bindLayoutButtons() { @@ -208,7 +229,7 @@ export function TimerApp(reference) { let shouldPlayTick = false; let shouldPlayStop = false; - let countdownTimer = null; + const countdownTimers = []; timers.forEach(timer => { const result = timer.tick(now); @@ -222,14 +243,14 @@ export function TimerApp(reference) { if (result.second <= COUNTDOWN_THRESHOLD) { shouldPlayTick = true; - if (!countdownTimer) countdownTimer = timer; + countdownTimers.push(timer); } }); if (shouldPlayStop) sound.playStop(); if (shouldPlayTick && !shouldPlayStop) sound.playTick(); - if (countdownTimer) renderCountdown(countdownTimer); + countdownTimers.forEach(renderCountdown); updatePageTitle(); persist(); ensureTicking(); @@ -306,6 +327,7 @@ export function TimerApp(reference) { saveState({ layout, theme: theme.getTheme(), + controlsHidden, timers: serializeTimers(), }); } @@ -316,6 +338,8 @@ export function TimerApp(reference) { getTimers: () => timers, getLayout: () => layout, setLayout: total => applyLayout(total, serializeTimers()), + isControlsHidden: () => controlsHidden, + setControlsHidden, }; } diff --git a/asset/javascript/__mocks__/timerDom.js b/asset/javascript/__mocks__/timerDom.js index 8f8a180..d6a8f08 100644 --- a/asset/javascript/__mocks__/timerDom.js +++ b/asset/javascript/__mocks__/timerDom.js @@ -10,6 +10,7 @@ export const TIMER_CARD_MARKUP = `
+ @@ -19,6 +20,12 @@ export const TIMER_CARD_MARKUP = `
+ +
+ + + +
`; @@ -30,6 +37,8 @@ export const APP_MARKUP = `
+ + diff --git a/asset/javascript/__tests__/TimeController.test.js b/asset/javascript/__tests__/TimeController.test.js index 21b63c4..08ef7b7 100644 --- a/asset/javascript/__tests__/TimeController.test.js +++ b/asset/javascript/__tests__/TimeController.test.js @@ -211,4 +211,63 @@ describe('TimerController', () => { expect(timer.getStatus()).toBe(TimerStatus.STOPPED); expect(timer.getRemaining()).toBe(300); }); + + it('liga e desliga o contador pelo botão do card', () => { + const counter = card.querySelector('.js-timer-counter'); + + expect(timer.isCounterEnabled()).toBe(false); + expect(counter.classList.contains('hide')).toBe(true); + + card.querySelector('.js-counter-toggle-button').click(); + + expect(timer.isCounterEnabled()).toBe(true); + expect(counter.classList.contains('hide')).toBe(false); + + card.querySelector('.js-counter-toggle-button').click(); + + expect(timer.isCounterEnabled()).toBe(false); + expect(counter.classList.contains('hide')).toBe(true); + }); + + it('incrementa, decrementa e aceita valor digitado no contador', () => { + const input = card.querySelector('.js-counter-value'); + + card.querySelector('.js-counter-increment-button').click(); + card.querySelector('.js-counter-increment-button').click(); + expect(timer.getCounter()).toBe(2); + expect(input.value).toBe('2'); + + card.querySelector('.js-counter-decrement-button').click(); + expect(timer.getCounter()).toBe(1); + + input.value = '15'; + input.dispatchEvent(new Event('change')); + expect(timer.getCounter()).toBe(15); + + input.value = 'abc'; + input.dispatchEvent(new Event('change')); + expect(timer.getCounter()).toBe(0); + }); + + it('serializa e restaura o contador', () => { + timer.toggleCounter(); + timer.setCounter(7); + + expect(timer.serialize().counter).toEqual({ enabled: true, value: 7 }); + + const otherCard = mountTimerCard(); + const other = TimerController(otherCard); + other.restore({ duration: 30, remaining: 30, status: 'STOPPED', counter: { enabled: true, value: 7 } }); + + expect(other.isCounterEnabled()).toBe(true); + expect(other.getCounter()).toBe(7); + expect(otherCard.querySelector('.js-counter-value').value).toBe('7'); + + other.restore({ duration: 30, remaining: 30, status: 'STOPPED' }); + + expect(other.isCounterEnabled()).toBe(false); + expect(other.getCounter()).toBe(0); + + other.destroy(); + }); }); diff --git a/asset/javascript/__tests__/TimerApp.test.js b/asset/javascript/__tests__/TimerApp.test.js index 278693c..78caa89 100644 --- a/asset/javascript/__tests__/TimerApp.test.js +++ b/asset/javascript/__tests__/TimerApp.test.js @@ -178,4 +178,48 @@ describe('TimerApp', () => { expect(restored.getLayout()).toBe(1); }); + + it('esconde e mostra os controles individuais pelo botão de olho', () => { + const hideButton = body.querySelector('.js-hide-controls-button'); + const showButton = body.querySelector('.js-show-controls-button'); + + expect(app.isControlsHidden()).toBe(false); + + hideButton.click(); + + expect(app.isControlsHidden()).toBe(true); + expect(body.classList.contains('controls-hidden')).toBe(true); + expect(hideButton.classList.contains('hide')).toBe(true); + expect(showButton.classList.contains('hide')).toBe(false); + + showButton.click(); + + expect(app.isControlsHidden()).toBe(false); + expect(body.classList.contains('controls-hidden')).toBe(false); + }); + + it('restaura o estado de controles escondidos', () => { + body.querySelector('.js-hide-controls-button').click(); + + const restored = TimerApp(mountApp()); + + expect(restored.isControlsHidden()).toBe(true); + expect(document.body.classList.contains('controls-hidden')).toBe(true); + }); + + it('mostra a contagem regressiva em todos os timers que chegam nos segundos finais juntos', () => { + selectLayout(2); + const [first, second] = app.getTimers(); + + first.restore({ duration: 30, remaining: 12, status: 'PAUSED' }); + second.restore({ duration: 30, remaining: 12, status: 'PAUSED' }); + body.querySelector('.js-start-all-button').click(); + + jest.advanceTimersByTime(3000); + + const numbers = Array.from(body.querySelectorAll('.js-timer-countdown')); + + expect(numbers.every(number => !number.classList.contains('hide'))).toBe(true); + expect(numbers.map(number => number.textContent)).toEqual(['9', '9']); + }); }); diff --git a/asset/style/timer.css b/asset/style/timer.css index f88c846..abc3d82 100644 --- a/asset/style/timer.css +++ b/asset/style/timer.css @@ -143,6 +143,55 @@ html.inverted .input-stopwatch-container input { box-shadow: 0 0 0 2px currentColor inset; } +.timer-counter { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} +.timer-counter .timer-counter-value { + width: 4ch; + min-width: 4ch; + margin: 0; + padding: 0 4px; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + background: none; + outline: none; + color: inherit; + font-size: calc(var(--clock-font-size) * 0.45); + font-variant-numeric: tabular-nums; + line-height: 1.1; + text-align: center; + transition: border-color 0.3s ease; +} +.timer-counter .timer-counter-value::-webkit-outer-spin-button, .timer-counter .timer-counter-value::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.timer-counter .timer-counter-value[type=number] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: textfield; +} +.timer-counter .timer-counter-value:focus { + border-bottom-color: currentColor; +} + +body.controls-hidden .timer-card .action-button-container { + display: none !important; +} + +.timer-card.inverted .timer-counter-value { + color: var(--primary-bg-color); +} + +button.as-icon.is-active { + background-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 0 0 1px currentColor inset; +} + .timer-name { width: 100%; max-width: 100%; @@ -354,5 +403,3 @@ button.as-icon:active, button.as-icon:focus, button.as-icon:focus-visible, butto .theme-dropdown button:hover { background: rgba(255, 255, 255, 0.1); } - -/*# sourceMappingURL=timer.css.map */ diff --git a/asset/style/timer.scss b/asset/style/timer.scss index 04c4547..61db3b4 100644 --- a/asset/style/timer.scss +++ b/asset/style/timer.scss @@ -146,6 +146,61 @@ html { box-shadow: 0 0 0 2px currentColor inset; } +.timer-counter { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + + .timer-counter-value { + width: 4ch; + min-width: 4ch; + margin: 0; + padding: 0 4px; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + background: none; + outline: none; + + color: inherit; + font-size: calc(var(--clock-font-size) * 0.45); + font-variant-numeric: tabular-nums; + line-height: 1.1; + text-align: center; + transition: border-color 0.3s ease; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type='number'] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: textfield; + } + + &:focus { + border-bottom-color: currentColor; + } + } +} + +body.controls-hidden .timer-card .action-button-container { + display: none !important; +} + +.timer-card.inverted .timer-counter-value { + color: var(--primary-bg-color); +} + +button.as-icon.is-active { + background-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 0 0 1px currentColor inset; +} + .timer-name { width: 100%; max-width: 100%; diff --git a/index.html b/index.html index 1b4821e..ed7c1c6 100644 --- a/index.html +++ b/index.html @@ -31,6 +31,9 @@
+ @@ -53,6 +56,17 @@ Finalizar
+ +
+ + + +
@@ -65,6 +79,12 @@ + +