From befccf054a32450810e0ce9315867c443929e397 Mon Sep 17 00:00:00 2001 From: Gabriel Nunes Date: Sat, 29 Aug 2026 08:47:27 -0300 Subject: [PATCH] =?UTF-8?q?adicionando=20m=C3=BAltiplos=20timers=20simult?= =?UTF-8?q?=C3=A2neos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite rodar 1, 2 ou 4 timers ao mesmo tempo, cada um com nome próprio, selecionáveis pelo menu lateral. - TimerApp orquestra os cards, o layout e um único tick de 250ms para todos - TimeController passa a controlar um card só, com estado baseado em deadline absoluto (sem drift e sem dessincronizar os timers entre si) - barra global com iniciar/pausar/resetar todos e o fullscreen - som e contagem regressiva extraídos para SoundController e CountdownOverlay, evitando áudio sobreposto quando dois timers terminam juntos - nos últimos 10s o card vira contador único em destaque; com 1 timer segue usando a tela cheia - ao zerar mostra 0 e depois "ACABOU!" fixo, com botão de reiniciar, no lugar do reset automático - layout, tema, nomes e tempos persistidos no localStorage, retomando timers em andamento após um reload - testes reescritos sobre DOM real, cobrindo o app e o card Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WSfxeczZchZHtnS4nAPDE8 --- asset/javascript/CountdownOverlay.js | 96 ++++ asset/javascript/SoundController.js | 79 ++++ asset/javascript/ThemeController.js | 44 +- asset/javascript/TimeController.js | 445 +++++++++--------- asset/javascript/TimerApp.js | 327 +++++++++++++ asset/javascript/TimerStorage.js | 36 ++ asset/javascript/__mocks__/TimeController.js | 61 --- asset/javascript/__mocks__/TimeUtils.js | 6 - asset/javascript/__mocks__/domMocks.js | 32 -- asset/javascript/__mocks__/timerDom.js | 72 +++ .../__tests__/TimeController.test.js | 329 ++++++++----- asset/javascript/__tests__/TimerApp.test.js | 181 +++++++ .../__tests__/TimerIntegration.test.js | 55 --- asset/style/timer.css | 179 ++++++- asset/style/timer.css.map | 2 +- asset/style/timer.scss | 192 +++++++- index.html | 125 +++-- package.json | 3 +- 18 files changed, 1688 insertions(+), 576 deletions(-) create mode 100644 asset/javascript/CountdownOverlay.js create mode 100644 asset/javascript/SoundController.js create mode 100644 asset/javascript/TimerApp.js create mode 100644 asset/javascript/TimerStorage.js delete mode 100644 asset/javascript/__mocks__/TimeController.js delete mode 100644 asset/javascript/__mocks__/TimeUtils.js delete mode 100644 asset/javascript/__mocks__/domMocks.js create mode 100644 asset/javascript/__mocks__/timerDom.js create mode 100644 asset/javascript/__tests__/TimerApp.test.js delete mode 100644 asset/javascript/__tests__/TimerIntegration.test.js diff --git a/asset/javascript/CountdownOverlay.js b/asset/javascript/CountdownOverlay.js new file mode 100644 index 0000000..e03febd --- /dev/null +++ b/asset/javascript/CountdownOverlay.js @@ -0,0 +1,96 @@ +import './helper.js'; + +export const FINISHED_MESSAGE = 'ACABOU!'; + +export function CountdownOverlay(reference) { + const container = reference.querySelector('.js-countdown-container'); + const number = container.querySelector('.js-countdown-number'); + const closeButton = container.querySelector('.js-close-countdown-button'); + const restartButton = container.querySelector('.js-restart-countdown-button'); + + let dismissed = false; + let onCloseCallback = null; + let onRestartCallback = null; + + function init() { + closeButton.addEventListener('click', close); + + if (restartButton) restartButton.addEventListener('click', restart); + } + + function show(seconds) { + if (dismissed) return; + + container.showElement(); + number.classList.remove('is-message'); + number.textContent = seconds; + + container.classList.toggle('even'); + container.classList.toggle('odd'); + + const isEven = seconds % 2 === 0; + container.classList.toggle('inverted', isEven); + reference.classList.toggle('inverted', isEven); + } + + function showFinished() { + if (dismissed) return; + + container.showElement(); + container.classList.remove('even', 'odd', 'inverted'); + reference.classList.remove('inverted'); + + number.classList.add('is-message'); + number.textContent = FINISHED_MESSAGE; + + if (restartButton) restartButton.showElement(); + } + + function restart() { + hide(); + + if (onRestartCallback) onRestartCallback(); + } + + function close() { + dismissed = true; + hide(); + + if (onCloseCallback) onCloseCallback(); + } + + function hide() { + reference.classList.remove('inverted'); + container.classList.remove('inverted'); + container.classList.remove('even', 'odd'); + container.hideElement(); + + number.classList.remove('is-message'); + number.textContent = ''; + + if (restartButton) restartButton.hideElement(); + } + + function reset() { + dismissed = false; + hide(); + } + + function isDismissed() { + return dismissed; + } + + function onClose(callback) { + onCloseCallback = callback; + } + + function onRestart(callback) { + onRestartCallback = callback; + } + + init(); + + return { show, showFinished, hide, reset, close, isDismissed, onClose, onRestart }; +} + +export default CountdownOverlay; diff --git a/asset/javascript/SoundController.js b/asset/javascript/SoundController.js new file mode 100644 index 0000000..d8b45f9 --- /dev/null +++ b/asset/javascript/SoundController.js @@ -0,0 +1,79 @@ +import tickTackSoundUrl from '../sound/tick-tack.wav'; +import stopSoundUrl from '../sound/stop.mp3'; + +const FADE_DELAY = 3000; +const FADE_INTERVAL = 200; +const FADE_STEP = 0.05; + +export function SoundController() { + const tickTackSound = new Audio(tickTackSoundUrl); + const stopSound = new Audio(stopSoundUrl); + + let fadeIntervalId = null; + let fadeTimeoutId = null; + + tickTackSound.addEventListener('error', e => { + console.error('Erro ao carregar tick-tack.wav:', e); + }); + + stopSound.addEventListener('error', e => { + console.error('Erro ao carregar stop.mp3:', e); + }); + + function safePlay(sound) { + const played = sound.play(); + + if (played && typeof played.catch === 'function') { + played.catch(() => {}); + } + } + + function playTick() { + tickTackSound.volume = 0.5; + tickTackSound.loop = false; + tickTackSound.currentTime = 0; + safePlay(tickTackSound); + } + + function playStop() { + clearFade(); + + stopSound.volume = 0.5; + stopSound.loop = false; + stopSound.currentTime = 0; + safePlay(stopSound); + + fadeTimeoutId = setTimeout(() => { + fadeIntervalId = setInterval(() => { + if (stopSound.volume > FADE_STEP) { + stopSound.volume -= FADE_STEP; + return; + } + + stopAll(); + }, FADE_INTERVAL); + }, FADE_DELAY); + } + + function clearFade() { + clearTimeout(fadeTimeoutId); + clearInterval(fadeIntervalId); + fadeTimeoutId = null; + fadeIntervalId = null; + } + + function stopAll() { + clearFade(); + + stopSound.pause(); + stopSound.currentTime = 0; + stopSound.volume = 0; + + tickTackSound.pause(); + tickTackSound.currentTime = 0; + } + + return { playTick, playStop, stopAll }; +} + +export default SoundController; diff --git a/asset/javascript/ThemeController.js b/asset/javascript/ThemeController.js index 0f118ef..a55b74b 100644 --- a/asset/javascript/ThemeController.js +++ b/asset/javascript/ThemeController.js @@ -1,3 +1,8 @@ +export const THEMES = { + DEFAULT: 'default', + VERSUS: 'versus', +}; + export function ThemeController(reference) { const body = reference; @@ -7,6 +12,9 @@ export function ThemeController(reference) { const defaultThemeButton = body.querySelector('.js-default-theme-button'); const versusThemeButton = body.querySelector('.js-versus-theme-button'); + let currentTheme = THEMES.DEFAULT; + let onChangeCallback = null; + function init() { if (!themeMenuButton || !themeDropdown || !defaultThemeButton || !versusThemeButton) { return; @@ -17,29 +25,43 @@ export function ThemeController(reference) { function bindButtons() { themeMenuButton.addEventListener('click', toggleThemeMenu); - defaultThemeButton.addEventListener('click', setDefaultTheme); - versusThemeButton.addEventListener('click', setVersusTheme); + defaultThemeButton.addEventListener('click', () => selectTheme(THEMES.DEFAULT)); + versusThemeButton.addEventListener('click', () => selectTheme(THEMES.VERSUS)); } function toggleThemeMenu() { themeDropdown.classList.toggle('hide'); } - function setDefaultTheme() { - body.classList.remove('versus-theme'); - body.classList.remove('inverted'); - closeThemeMenu(); + function selectTheme(theme) { + setTheme(theme); + closeMenu(); + + if (onChangeCallback) onChangeCallback(currentTheme); } - function setVersusTheme() { - body.classList.add('versus-theme'); + function setTheme(theme) { + currentTheme = theme === THEMES.VERSUS ? THEMES.VERSUS : THEMES.DEFAULT; + + body.classList.toggle('versus-theme', currentTheme === THEMES.VERSUS); body.classList.remove('inverted'); - closeThemeMenu(); } - function closeThemeMenu() { - themeDropdown.classList.add('hide'); + function closeMenu() { + if (themeDropdown) themeDropdown.classList.add('hide'); + } + + function getTheme() { + return currentTheme; + } + + function onChange(callback) { + onChangeCallback = callback; } init(); + + return { setTheme, getTheme, closeMenu, onChange }; } + +export default ThemeController; diff --git a/asset/javascript/TimeController.js b/asset/javascript/TimeController.js index 5a78148..ad44e3d 100644 --- a/asset/javascript/TimeController.js +++ b/asset/javascript/TimeController.js @@ -1,5 +1,3 @@ -import tickTackSoundUrl from '../sound/tick-tack.wav'; -import stopSoundUrl from '../sound/stop.mp3'; import './helper.js'; import { TimerStatus } from './TimerStatus.js'; import { @@ -10,17 +8,22 @@ import { secondsToHour, secondsToMinute, } from './TimeUtils.js'; -import { ThemeController } from './ThemeController.js'; -function TimerController(reference) { +export const DEFAULT_SECONDS = 30; +export const COUNTDOWN_THRESHOLD = 10; +const FINISHED_MESSAGE_DELAY = 1000; +export const FINISHED_MESSAGE = 'ACABOU!'; + +function TimerController(reference, hooks = {}) { + const nameInput = reference.querySelector('.js-timer-name'); const hourInput = reference.querySelector('.js-hour-input'); const minuteInput = reference.querySelector('.js-minute-input'); const secondInput = reference.querySelector('.js-seconds-input'); - const themeButton = reference.querySelector('.js-theme-menu-container'); + + const clockContainer = reference.querySelector('.input-stopwatch-container'); + const countdownNumber = reference.querySelector('.js-timer-countdown'); const actionButtonsContainer = reference.querySelector('.js-stopwatch-action-buttons'); - const enterFullscreenButton = actionButtonsContainer.querySelector('.js-enter-fullscreen-button'); - const exitFullscreenButton = actionButtonsContainer.querySelector('.js-exit-fullscreen-button'); const startButton = actionButtonsContainer.querySelector('.js-start-button'); const stopButton = actionButtonsContainer.querySelector('.js-stop-button'); const pauseButton = actionButtonsContainer.querySelector('.js-pause-button'); @@ -30,40 +33,36 @@ function TimerController(reference) { const cancelEditButton = editActionButtonsContainer.querySelector('.js-cancel-edit-button'); const finishEditButton = editActionButtonsContainer.querySelector('.js-finish-edit-button'); - const countdownContainerReference = reference.querySelector('.js-countdown-container'); - const countdownNumber = countdownContainerReference.querySelector('.js-countdown-number'); - const closeCountdownButton = countdownContainerReference.querySelector('.js-close-countdown-button'); - - const DEFAULT_INTERVAL = 1000; - const DEFAULT_SECONDS = 30; - - const tickTackSound = new Audio(tickTackSoundUrl); - const stopSound = new Audio(stopSoundUrl); - tickTackSound.addEventListener('error', e => { - console.error('Erro ao carregar tick-tack.wav:', e); - }); - - stopSound.addEventListener('error', e => { - console.error('Erro ao carregar stop.mp3:', e); - }); - - let lastTimerStatus = TimerStatus.STOPPED; + let status = TimerStatus.STOPPED; + let duration = DEFAULT_SECONDS; + let remaining = DEFAULT_SECONDS; + let deadline = null; let previousTimerValue = DEFAULT_SECONDS; - let timerIntervalId = null; - let preventOpenCountdown = false; + let resetTimeoutId = null; function init() { bindInputs(); bindButtons(); - bindFullscreenEvents(); - setInputValues(DEFAULT_SECONDS); - new ThemeController(reference); + setRemaining(DEFAULT_SECONDS); + } + + function notifyChange() { + if (hooks.onChange) hooks.onChange(); } var bindInputs = function () { + if (nameInput) { + nameInput.addEventListener('input', notifyChange); + nameInput.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + nameInput.blur(); + } + }); + } + hourInput.addEventListener('input', function () { - let maxHours = 99; - validateInput(hourInput, maxHours); + validateInput(hourInput, 99); }); hourInput.addEventListener('keydown', function (event) { @@ -75,8 +74,7 @@ function TimerController(reference) { }); minuteInput.addEventListener('input', function () { - let maxMinutes = 59; - validateInput(minuteInput, maxMinutes); + validateInput(minuteInput, 59); }); minuteInput.addEventListener('keydown', function (event) { @@ -88,8 +86,7 @@ function TimerController(reference) { }); secondInput.addEventListener('input', function () { - let maxSeconds = 59; - validateInput(secondInput, maxSeconds); + validateInput(secondInput, 59); }); secondInput.addEventListener('keydown', function (event) { @@ -102,17 +99,6 @@ function TimerController(reference) { }); }; - function bindFullscreenEvents() { - document.addEventListener('fullscreenchange', handleButtonFullscreenChange); - document.addEventListener('visibilitychange', handleVisibilityChange); - } - - function handleVisibilityChange() { - if (document.hidden) return; - - updatePageTitle(); - } - function validateInput(input, maxValue) { let value = parseInt(input.value) || 0; @@ -130,14 +116,12 @@ function TimerController(reference) { startButton.addEventListener('click', start); stopButton.addEventListener('click', stop); pauseButton.addEventListener('click', pause); - - enterFullscreenButton.addEventListener('click', handleFullscreen); - exitFullscreenButton.addEventListener('click', handleFullscreen); - closeCountdownButton.addEventListener('click', closeCountdownContainer); } function openEditInput() { - lastTimerStatus = TimerStatus.EDITING; + if (TimerStatus.isRunning(status) || TimerStatus.isCountdown(status)) return; + + status = TimerStatus.EDITING; previousTimerValue = getInputsValueAsSeconds(); toggleDisableInputs(false); @@ -154,188 +138,117 @@ function TimerController(reference) { } function finishEditInput() { - lastTimerStatus = TimerStatus.PAUSED; toggleDisableInputs(true); toggleButtonsContainer(false); - window.getSelection().removeAllRanges(); - let seconds = getInputsValueAsSeconds(); - - if (seconds <= 0) { - setInputValues(DEFAULT_SECONDS); - } - } + if (window.getSelection()) window.getSelection().removeAllRanges(); - function start() { - const canStart = TimerStatus.isStopped(lastTimerStatus) || TimerStatus.isPaused(lastTimerStatus); + let seconds = getInputsValueAsSeconds(); - if (!canStart) return; + if (seconds <= 0) seconds = DEFAULT_SECONDS; - reference.classList.remove('inverted'); - countdownContainerReference.classList.remove('inverted'); - startButton.hideElement(); - pauseButton.showElement(); - stopButton.showElement(); - editButton.hideElement(); - - lastTimerStatus = TimerStatus.RUNNING; - initTimer(); + status = TimerStatus.STOPPED; + duration = seconds; + setRemaining(seconds); + notifyChange(); } - function initTimer() { - timerIntervalId = setInterval(() => { - var canStart = - TimerStatus.isRunning(lastTimerStatus) || - TimerStatus.isCountdown(lastTimerStatus) || - TimerStatus.isPaused(lastTimerStatus); - - if (!canStart) { - clearInterval(timerIntervalId); - return; - } - - let seconds = getInputsValueAsSeconds(); - seconds--; - if (seconds <= 10) { - lastTimerStatus = TimerStatus.COUNTDOWN; - playCountdownSound(); + function isEditing() { + return status === TimerStatus.EDITING; + } - if (!preventOpenCountdown) { - themeButton.hideElement(); - executeCountdown(seconds); - } - } + function isActive() { + return TimerStatus.isRunning(status) || TimerStatus.isCountdown(status); + } - setInputValues(seconds); + function start() { + const canStart = TimerStatus.isStopped(status) || TimerStatus.isPaused(status); - if (seconds == 0) { - preventOpenCountdown = false; - lastTimerStatus = TimerStatus.STOPPED; + if (!canStart) return; - showDefaultButtons(); - playStopSound(); - clearInterval(timerIntervalId); - } - }, DEFAULT_INTERVAL); - } + clearTimeout(resetTimeoutId); + hideCountdownNumber(); + setInverted(false); - function playCountdownSound() { - tickTackSound.volume = 0.5; - tickTackSound.loop = false; - tickTackSound.currentTime = 0; - tickTackSound.play(); - } + if (remaining <= 0) setRemaining(duration); - function playStopSound() { - stopSound.volume = 0.5; - stopSound.loop = false; - stopSound.currentTime = 0; - stopSound.play(); + deadline = Date.now() + remaining * 1000; + status = remaining <= COUNTDOWN_THRESHOLD ? TimerStatus.COUNTDOWN : TimerStatus.RUNNING; - setTimeout(() => { - const fade = setInterval(() => { - if (stopSound.volume > 0.05) { - stopSound.volume -= 0.05; - return; - } + startButton.hideElement(); + pauseButton.showElement(); + stopButton.showElement(); + editButton.hideElement(); - stopSound.volume = 0; - stopSound.pause(); - stopSound.currentTime = 0; - clearInterval(fade); - setInputValues(DEFAULT_SECONDS); - preventOpenCountdown = false; - }, 200); - }, 3000); + notifyChange(); } - function executeCountdown(seconds) { - countdownContainerReference.showElement(); - countdownNumber.textContent = seconds; - - countdownContainerReference.classList.toggle('even'); - countdownContainerReference.classList.toggle('odd'); + function pause() { + if (!isActive()) return; - if (seconds % 2 === 0) { - reference.classList.add('inverted'); - countdownContainerReference.classList.add('inverted'); + status = TimerStatus.PAUSED; + deadline = null; - return; - } + startButton.showElement(); + pauseButton.hideElement(); - reference.classList.remove('inverted'); - countdownContainerReference.classList.remove('inverted'); + notifyChange(); } function stop() { - if (TimerStatus.isStopped(lastTimerStatus)) return; + clearTimeout(resetTimeoutId); - preventOpenCountdown = false; - lastTimerStatus = TimerStatus.STOPPED; - reference.classList.remove('inverted'); - countdownContainerReference.classList.remove('inverted'); + status = TimerStatus.STOPPED; + deadline = null; + setInverted(false); + hideCountdownNumber(); showDefaultButtons(); - setInputValues(DEFAULT_SECONDS); - clearInterval(timerIntervalId); + setRemaining(duration); + notifyChange(); } - function pause() { - const shouldNotPause = lastTimerStatus !== TimerStatus.RUNNING && lastTimerStatus !== TimerStatus.COUNTDOWN; - if (shouldNotPause) return; + function tick(now) { + if (!isActive()) return null; - lastTimerStatus = TimerStatus.PAUSED; + const next = Math.max(0, Math.round((deadline - now) / 1000)); - startButton.showElement(); - pauseButton.hideElement(); + if (next === remaining) return null; - clearInterval(timerIntervalId); - } + setRemaining(next); - function isInFullscreen() { - return !!document.fullscreenElement; - } - - function handleButtonFullscreenChange() { - if (isInFullscreen()) { - exitFullscreenButton.showElement(); - enterFullscreenButton.hideElement(); - return; + if (remaining > 0) { + if (remaining <= COUNTDOWN_THRESHOLD) status = TimerStatus.COUNTDOWN; + return { second: remaining, finished: false }; } - enterFullscreenButton.showElement(); - exitFullscreenButton.hideElement(); + finish(); + + return { second: 0, finished: true }; } - function handleFullscreen() { - if (!document.fullscreenElement) { - document.documentElement.requestFullscreen(); - return; - } + function finish() { + status = TimerStatus.STOPPED; + deadline = null; + showDefaultButtons(); - document.exitFullscreen(); - } + if (hooks.onFinish) hooks.onFinish(); - function closeCountdownContainer() { - preventOpenCountdown = true; - reference.classList.remove('inverted'); - countdownContainerReference.classList.remove('inverted'); - countdownContainerReference.hideElement(); - countdownContainerReference.classList.remove('even', 'odd'); - countdownNumber.textContent = ''; - stopSound.pause(); - stopSound.currentTime = 0; - stopSound.volume = 0; - themeButton.showElement(); + resetTimeoutId = setTimeout(() => { + if (hooks.onFinishedMessage) hooks.onFinishedMessage(); + }, FINISHED_MESSAGE_DELAY); + + notifyChange(); } - function toggleButtonsContainer(isEditing) { - if (isEditing) { + function toggleButtonsContainer(editing) { + if (editing) { actionButtonsContainer.hideElement(); editActionButtonsContainer.showElement(); - } else { - actionButtonsContainer.showElement(); - editActionButtonsContainer.hideElement(); + return; } + + actionButtonsContainer.showElement(); + editActionButtonsContainer.hideElement(); } function toggleDisableInputs(disable) { @@ -354,10 +267,7 @@ function TimerController(reference) { function getInputsValueAsSeconds() { const { seconds, minutes, hours } = getInputValues(); - const minutesAsSeconds = minuteToSeconds(minutes); - const hourAsSeconds = hourToSeconds(hours); - - return seconds + minutesAsSeconds + hourAsSeconds; + return seconds + minuteToSeconds(minutes) + hourToSeconds(hours); } function getInputValues() { @@ -369,29 +279,142 @@ function TimerController(reference) { } function setInputValues(totalSeconds = 0) { - const hours = secondsToHour(totalSeconds); - const minutes = secondsToMinute(totalSeconds); - const seconds = remainingSeconds(totalSeconds); + hourInput.value = formatTimeUnit(secondsToHour(totalSeconds)); + minuteInput.value = formatTimeUnit(secondsToMinute(totalSeconds)); + secondInput.value = formatTimeUnit(remainingSeconds(totalSeconds)); + } - hourInput.value = formatTimeUnit(hours); - minuteInput.value = formatTimeUnit(minutes); - secondInput.value = formatTimeUnit(seconds); + function setRemaining(totalSeconds) { + remaining = Math.max(0, totalSeconds); + setInputValues(remaining); + } - updatePageTitle(); + function setInverted(inverted) { + reference.classList.toggle('inverted', !!inverted); } - function updatePageTitle() { - const { seconds, minutes, hours } = getInputValues(); - document.title = `${formatTimeUnit(hours)}:${formatTimeUnit(minutes)}:${formatTimeUnit(seconds)} - Timer `; + function showCountdownNumber(seconds) { + if (!countdownNumber) return; + + countdownNumber.textContent = seconds; + countdownNumber.classList.remove('is-message'); + countdownNumber.showElement(); + reference.classList.remove('is-finished'); + clockContainer.hideElement(); + reference.classList.add('is-countdown'); + } + + function showFinishedMessage() { + if (!countdownNumber) return; + + setInverted(false); + countdownNumber.textContent = FINISHED_MESSAGE; + countdownNumber.classList.add('is-message'); + countdownNumber.showElement(); + clockContainer.hideElement(); + reference.classList.add('is-countdown', 'is-finished'); + } + + function hideCountdownNumber() { + if (!countdownNumber) return; + + countdownNumber.textContent = ''; + countdownNumber.classList.remove('is-message'); + countdownNumber.hideElement(); + clockContainer.showElement(); + reference.classList.remove('is-countdown', 'is-finished'); + } + + function getName() { + return nameInput ? nameInput.value.trim() : ''; + } + + function setName(name) { + if (nameInput) nameInput.value = name; + } + + function setPlaceholder(placeholder) { + if (nameInput) nameInput.placeholder = placeholder; + } + + function serialize() { + return { + name: getName(), + duration, + remaining, + status, + endsAt: isActive() ? deadline : null, + }; + } + + function restore(data = {}) { + clearTimeout(resetTimeoutId); + setInverted(false); + hideCountdownNumber(); + + setName(data.name || ''); + duration = data.duration > 0 ? data.duration : DEFAULT_SECONDS; + + const wasActive = data.status === TimerStatus.RUNNING || data.status === TimerStatus.COUNTDOWN; + + if (wasActive && data.endsAt) { + const left = Math.round((data.endsAt - Date.now()) / 1000); + + if (left > 0) { + setRemaining(left); + deadline = data.endsAt; + status = left <= COUNTDOWN_THRESHOLD ? TimerStatus.COUNTDOWN : TimerStatus.RUNNING; + + startButton.hideElement(); + pauseButton.showElement(); + stopButton.showElement(); + editButton.hideElement(); + return; + } + + status = TimerStatus.STOPPED; + deadline = null; + showDefaultButtons(); + setRemaining(duration); + return; + } + + deadline = null; + status = data.status === TimerStatus.PAUSED ? TimerStatus.PAUSED : TimerStatus.STOPPED; + setRemaining(data.remaining > 0 ? data.remaining : duration); + showDefaultButtons(); + + if (TimerStatus.isPaused(status)) stopButton.showElement(); + } + + function destroy() { + clearTimeout(resetTimeoutId); } init(); -} -document.addEventListener('DOMContentLoaded', function () { - let reference = document.querySelector('.js-body'); - const timerController = new TimerController(reference); - window.timerController = timerController; -}); + return { + element: reference, + start, + pause, + stop, + tick, + getName, + setName, + setPlaceholder, + setInverted, + showCountdownNumber, + showFinishedMessage, + hideCountdownNumber, + isActive, + isEditing, + getStatus: () => status, + getRemaining: () => remaining, + getDuration: () => duration, + serialize, + restore, + destroy, + }; +} export default TimerController; diff --git a/asset/javascript/TimerApp.js b/asset/javascript/TimerApp.js new file mode 100644 index 0000000..bbf8d86 --- /dev/null +++ b/asset/javascript/TimerApp.js @@ -0,0 +1,327 @@ +import './helper.js'; +import TimerController, { COUNTDOWN_THRESHOLD } from './TimeController.js'; +import { CountdownOverlay } from './CountdownOverlay.js'; +import { SoundController } from './SoundController.js'; +import { ThemeController } from './ThemeController.js'; +import { loadState, saveState } from './TimerStorage.js'; +import { formatTimeUnit, remainingSeconds, secondsToHour, secondsToMinute } from './TimeUtils.js'; + +const TICK_INTERVAL = 250; +const AVAILABLE_LAYOUTS = [1, 2, 4]; +const DEFAULT_LAYOUT = 1; + +export function TimerApp(reference) { + const grid = reference.querySelector('.js-timers-grid'); + const template = reference.querySelector('.js-timer-template'); + + const globalControls = reference.querySelector('.js-global-controls'); + const startAllButton = globalControls.querySelector('.js-start-all-button'); + const pauseAllButton = globalControls.querySelector('.js-pause-all-button'); + 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 layoutButtons = Array.from(reference.querySelectorAll('.js-layout-button')); + const themeMenuContainer = reference.querySelector('.js-theme-menu-container'); + + const overlay = CountdownOverlay(reference); + const sound = SoundController(); + const theme = ThemeController(reference); + + let timers = []; + let layout = DEFAULT_LAYOUT; + let tickIntervalId = null; + + function init() { + bindGlobalButtons(); + bindLayoutButtons(); + bindFullscreenEvents(); + overlay.onClose(handleOverlayClose); + overlay.onRestart(handleOverlayRestart); + theme.onChange(persist); + + restoreFromStorage(); + } + + function restoreFromStorage() { + const state = loadState(); + + if (state && AVAILABLE_LAYOUTS.includes(state.layout)) { + theme.setTheme(state.theme); + applyLayout(state.layout, state.timers || []); + return; + } + + applyLayout(DEFAULT_LAYOUT, []); + } + + function bindGlobalButtons() { + startAllButton.addEventListener('click', () => forEachTimer(timer => timer.start())); + pauseAllButton.addEventListener('click', () => forEachTimer(timer => timer.pause())); + stopAllButton.addEventListener('click', () => { + sound.stopAll(); + overlay.reset(); + forEachTimer(timer => timer.stop()); + }); + + enterFullscreenButton.addEventListener('click', handleFullscreen); + exitFullscreenButton.addEventListener('click', handleFullscreen); + } + + function bindLayoutButtons() { + layoutButtons.forEach(button => { + button.addEventListener('click', () => { + const total = parseInt(button.dataset.timers, 10); + + if (!AVAILABLE_LAYOUTS.includes(total)) return; + + applyLayout(total, serializeTimers()); + theme.closeMenu(); + persist(); + }); + }); + } + + function bindFullscreenEvents() { + document.addEventListener('fullscreenchange', handleFullscreenChange); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) updatePageTitle(); + }); + } + + function forEachTimer(callback) { + timers.forEach(callback); + ensureTicking(); + persist(); + } + + function applyLayout(total, savedTimers) { + layout = total; + + timers.forEach(timer => timer.destroy()); + timers = []; + grid.innerHTML = ''; + + AVAILABLE_LAYOUTS.forEach(option => { + reference.classList.toggle(`timers-${option}`, option === total); + }); + + layoutButtons.forEach(button => { + const isActive = parseInt(button.dataset.timers, 10) === total; + button.classList.toggle('is-active', isActive); + }); + + for (let index = 0; index < total; index++) { + timers.push(createTimer(index, savedTimers[index])); + } + + globalControls.classList.toggle('is-multiple', total > 1); + startAllButton.classList.toggle('hide', total === 1); + pauseAllButton.classList.toggle('hide', total === 1); + stopAllButton.classList.toggle('hide', total === 1); + + overlay.reset(); + ensureTicking(); + updatePageTitle(); + } + + function createTimer(index, savedTimer) { + const fragment = template.content.cloneNode(true); + const card = fragment.querySelector('.js-timer-card'); + + grid.appendChild(fragment); + + let timer; + + timer = TimerController(card, { + onChange: handleTimerChange, + onFinish: () => handleFinish(timer), + onFinishedMessage: () => handleFinishedMessage(timer), + }); + + timer.setPlaceholder(`Timer ${index + 1}`); + + if (savedTimer) timer.restore(savedTimer); + + return timer; + } + + function handleFinish(timer) { + if (isSingleLayout()) { + overlay.show(0); + return; + } + + timer.setInverted(false); + timer.showCountdownNumber(0); + } + + function handleFinishedMessage(timer) { + if (isSingleLayout()) { + overlay.showFinished(); + return; + } + + timer.showFinishedMessage(); + } + + function handleOverlayRestart() { + themeMenuContainer.showElement(); + timers.forEach(timer => timer.stop()); + overlay.reset(); + } + + function handleTimerChange() { + releaseOverlayWhenRestarted(); + ensureTicking(); + updatePageTitle(); + persist(); + } + + function releaseOverlayWhenRestarted() { + if (!isSingleLayout() || !timers[0]) return; + + const isBackToNormal = timers[0].getRemaining() > COUNTDOWN_THRESHOLD; + + if (!isBackToNormal) return; + + overlay.reset(); + themeMenuContainer.showElement(); + } + + function ensureTicking() { + const hasActive = timers.some(timer => timer.isActive()); + + if (hasActive && !tickIntervalId) { + tickIntervalId = setInterval(tick, TICK_INTERVAL); + return; + } + + if (!hasActive && tickIntervalId) { + clearInterval(tickIntervalId); + tickIntervalId = null; + } + } + + function tick() { + const now = Date.now(); + + let shouldPlayTick = false; + let shouldPlayStop = false; + let countdownTimer = null; + + timers.forEach(timer => { + const result = timer.tick(now); + + if (!result) return; + + if (result.finished) { + shouldPlayStop = true; + return; + } + + if (result.second <= COUNTDOWN_THRESHOLD) { + shouldPlayTick = true; + if (!countdownTimer) countdownTimer = timer; + } + }); + + if (shouldPlayStop) sound.playStop(); + if (shouldPlayTick && !shouldPlayStop) sound.playTick(); + + if (countdownTimer) renderCountdown(countdownTimer); + updatePageTitle(); + persist(); + ensureTicking(); + } + + function renderCountdown(timer) { + if (isSingleLayout()) { + if (!overlay.isDismissed()) themeMenuContainer.hideElement(); + overlay.show(timer.getRemaining()); + return; + } + + timer.showCountdownNumber(timer.getRemaining()); + timer.setInverted(timer.getRemaining() % 2 === 0); + } + + function isSingleLayout() { + return layout === 1; + } + + function handleOverlayClose() { + themeMenuContainer.showElement(); + } + + function handleFullscreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + return; + } + + document.exitFullscreen(); + } + + function handleFullscreenChange() { + if (document.fullscreenElement) { + exitFullscreenButton.showElement(); + enterFullscreenButton.hideElement(); + return; + } + + enterFullscreenButton.showElement(); + exitFullscreenButton.hideElement(); + } + + function getLeadingTimer() { + const active = timers.filter(timer => timer.isActive()); + + if (active.length === 0) return timers[0]; + + return active.reduce((closest, timer) => (timer.getRemaining() < closest.getRemaining() ? timer : closest)); + } + + function updatePageTitle() { + const timer = getLeadingTimer(); + + if (!timer) return; + + const total = timer.getRemaining(); + const time = [secondsToHour(total), secondsToMinute(total), remainingSeconds(total)] + .map(formatTimeUnit) + .join(':'); + + const name = timer.getName(); + const label = name && timers.length > 1 ? `${time} ${name}` : time; + + document.title = `${label} - Timer `; + } + + function serializeTimers() { + return timers.map(timer => timer.serialize()); + } + + function persist() { + saveState({ + layout, + theme: theme.getTheme(), + timers: serializeTimers(), + }); + } + + init(); + + return { + getTimers: () => timers, + getLayout: () => layout, + setLayout: total => applyLayout(total, serializeTimers()), + }; +} + +document.addEventListener('DOMContentLoaded', function () { + const reference = document.querySelector('.js-body'); + window.timerApp = TimerApp(reference); +}); + +export default TimerApp; diff --git a/asset/javascript/TimerStorage.js b/asset/javascript/TimerStorage.js new file mode 100644 index 0000000..39ecc12 --- /dev/null +++ b/asset/javascript/TimerStorage.js @@ -0,0 +1,36 @@ +const STORAGE_KEY = 'codecon-timer-state-v1'; + +export function loadState() { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + + if (!raw) return null; + + const parsed = JSON.parse(raw); + + if (!parsed || typeof parsed !== 'object') return null; + + return parsed; + } catch (error) { + console.warn('Não foi possível ler o estado salvo:', error); + return null; + } +} + +export function saveState(state) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.warn('Não foi possível salvar o estado:', error); + } +} + +export function clearState() { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.warn('Não foi possível limpar o estado:', error); + } +} + +export { STORAGE_KEY }; diff --git a/asset/javascript/__mocks__/TimeController.js b/asset/javascript/__mocks__/TimeController.js deleted file mode 100644 index 3ef0f29..0000000 --- a/asset/javascript/__mocks__/TimeController.js +++ /dev/null @@ -1,61 +0,0 @@ -import { jest } from '@jest/globals'; - -// Mock de dependências externas -jest.mock('../../sound/tick-tack.wav', () => 'tick-tack.wav'); -jest.mock('../../sound/stop.mp3', () => 'stop.mp3'); -jest.mock('../helper.js', () => ({}), { virtual: true }); - -// Mock do TimerStatus e TimeUtils -jest.mock('../TimerStatus.js', () => ({ - TimerStatus: { - STOPPED: 'STOPPED', - PAUSED: 'PAUSED', - COUNTDOWN: 'COUNTDOWN', - RUNNING: 'RUNNING', - EDITING: 'EDITING', - isRunning: jest.fn(), - isCountdown: jest.fn(), - isPaused: jest.fn(), - isStopped: jest.fn(), - } -})); - -jest.mock('../TimeUtils.js', () => ({ - formatTimeUnit: jest.fn(v => String(v).padStart(2, '0').slice(-2)), - hourToSeconds: jest.fn(h => h * 3600), - minuteToSeconds: jest.fn(m => m * 60), - remainingSeconds: jest.fn(s => s % 60), - secondsToHour: jest.fn(s => Math.floor(s / 3600)), - secondsToMinute: jest.fn(s => Math.floor((s % 3600) / 60)), -})); -function createMockInput() { - return { - value: '00', - addEventListener: jest.fn(), - focus: jest.fn(), - disabled: false, - }; -} -function createMockButton() { - return { - addEventListener: jest.fn(), - showElement: jest.fn(), - hideElement: jest.fn(), - focus: jest.fn(), - }; -} -function createMockContainer() { - return { - querySelector: jest.fn((selector) => { - if (selector.includes('input')) return createMockInput(); - if (selector.includes('button')) return createMockButton(); - if (selector.includes('number')) return { textContent: '', classList: { add: jest.fn(), remove: jest.fn() } }; - return {}; - }), - showElement: jest.fn(), - hideElement: jest.fn(), - classList: { add: jest.fn(), remove: jest.fn() }, - }; -} - -export { createMockInput, createMockButton, createMockContainer }; \ No newline at end of file diff --git a/asset/javascript/__mocks__/TimeUtils.js b/asset/javascript/__mocks__/TimeUtils.js deleted file mode 100644 index 5c2e576..0000000 --- a/asset/javascript/__mocks__/TimeUtils.js +++ /dev/null @@ -1,6 +0,0 @@ -export const formatTimeUnit = jest.fn(value => value.toString().padStart(2, '0')); -export const hourToSeconds = jest.fn(hours => hours * 3600); -export const minuteToSeconds = jest.fn(minutes => minutes * 60); -export const remainingSeconds = jest.fn(seconds => seconds % 60); -export const secondsToHour = jest.fn(seconds => Math.floor(seconds / 3600)); -export const secondsToMinute = jest.fn(seconds => Math.floor((seconds % 3600) / 60)); \ No newline at end of file diff --git a/asset/javascript/__mocks__/domMocks.js b/asset/javascript/__mocks__/domMocks.js deleted file mode 100644 index bc4fd49..0000000 --- a/asset/javascript/__mocks__/domMocks.js +++ /dev/null @@ -1,32 +0,0 @@ -export const mockElement = (() => { - const cache = {}; - - return () => ({ - querySelector: jest.fn(selector => { - if (!cache[selector]) { - if (String(selector).includes('.js-*-button')) { - cache[selector] = { - addEventListener: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - } else { - cache[selector] = mockElement(); - } - } - return cache[selector]; - }), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - focus: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - classList: { - add: jest.fn(), - remove: jest.fn(), - }, - value: '00', - textContent: '', - disabled: false, - }); -})(); \ No newline at end of file diff --git a/asset/javascript/__mocks__/timerDom.js b/asset/javascript/__mocks__/timerDom.js new file mode 100644 index 0000000..8f8a180 --- /dev/null +++ b/asset/javascript/__mocks__/timerDom.js @@ -0,0 +1,72 @@ +export const TIMER_CARD_MARKUP = ` +
+ +
+ + : + + : + +
+ +
+ + + + +
+
+ + +
+
+`; + +export const APP_MARKUP = ` + + +
+ +
+ + + + + +
+ +
+ +
+
+ + + +
+ + +
+
+ +
+ +
+ + +
+
+`; + +export function mountTimerCard() { + document.body.className = 'js-body timers-1'; + document.body.innerHTML = TIMER_CARD_MARKUP; + + return document.body.querySelector('.js-timer-card'); +} + +export function mountApp() { + document.body.className = 'js-body timers-1'; + document.body.innerHTML = APP_MARKUP; + + return document.body; +} diff --git a/asset/javascript/__tests__/TimeController.test.js b/asset/javascript/__tests__/TimeController.test.js index 8f96fee..21b63c4 100644 --- a/asset/javascript/__tests__/TimeController.test.js +++ b/asset/javascript/__tests__/TimeController.test.js @@ -1,123 +1,214 @@ -import TimerController from '../TimeController'; -import * as TimeUtils from '../TimeUtils'; - -jest.mock('../TimeUtils'); +import TimerController, { DEFAULT_SECONDS } from '../TimeController'; +import { TimerStatus } from '../TimerStatus'; +import { mountTimerCard } from '../__mocks__/timerDom'; describe('TimerController', () => { - let mockReference; + let card; + let timer; + + const click = selector => card.querySelector(selector).click(); + const displayedTime = () => + [ + card.querySelector('.js-hour-input').value, + card.querySelector('.js-minute-input').value, + card.querySelector('.js-seconds-input').value, + ].join(':'); beforeEach(() => { - mockReference = { - querySelector: jest.fn(selector => { - if (String(selector).includes('input')) { - return { - addEventListener: jest.fn(), - value: '00', - disabled: false, - }; - } - if (selector === '.js-stopwatch-action-buttons') { - return { - querySelector: jest.fn(innerSelector => { - if (innerSelector === '.js-start-button') { - return { - addEventListener: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - } - return { - addEventListener: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - }), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - } - if (selector === '.js-edit-container-stopwatch') { - return { - querySelector: jest.fn(innerSelector => ({ - addEventListener: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - })), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - } - if (selector === '.js-countdown-container') { - return { - querySelector: jest.fn(innerSelector => { - if (innerSelector === '.js-close-countdown-button') { - return { - addEventListener: jest.fn(), - }; - } - return { - textContent: '', - }; - }), - hideElement: jest.fn(), - showElement: jest.fn(), - classList: { - add: jest.fn(), - remove: jest.fn(), - }, - textContent: '', - }; - } - if (selector === '.js-stopwatch-action-buttons') { - return { - querySelector: jest.fn(innerSelector => { - if (innerSelector === '.js-start-button') { - const mockAddEventListener = jest.fn(); - return { - addEventListener: mockAddEventListener, - hideElement: jest.fn(), - showElement: jest.fn(), - }; - } - if (innerSelector === '.js-pause-button') { - return { - addEventListener: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - } - return { - addEventListener: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - }), - hideElement: jest.fn(), - showElement: jest.fn(), - }; - } - return { - addEventListener: jest.fn(), - hideElement: jest.fn(), - showElement: jest.fn(), - focus: jest.fn(), - value: '00', - classList: { add: jest.fn(), remove: jest.fn() }, - }; - }), - }; - }); - - - it('deve inicializar corretamente', () => { - const timerController = new TimerController(mockReference); - expect(mockReference.querySelector).toHaveBeenCalledWith('.js-hour-input'); - }); - - it('deve formatar corretamente os valores de tempo', () => { - TimeUtils.formatTimeUnit.mockReturnValue('05'); - const result = TimeUtils.formatTimeUnit(5); - expect(result).toBe('05'); - expect(TimeUtils.formatTimeUnit).toHaveBeenCalledWith(5); - }); -}); \ No newline at end of file + jest.useFakeTimers(); + card = mountTimerCard(); + timer = TimerController(card); + }); + + afterEach(() => { + timer.destroy(); + jest.useRealTimers(); + }); + + it('inicia com o tempo padrão e parado', () => { + expect(displayedTime()).toBe('00:00:30'); + expect(timer.getStatus()).toBe(TimerStatus.STOPPED); + }); + + it('inicia, pausa e para alternando os botões', () => { + click('.js-start-button'); + + expect(timer.getStatus()).toBe(TimerStatus.RUNNING); + expect(card.querySelector('.js-start-button').classList.contains('hide')).toBe(true); + expect(card.querySelector('.js-pause-button').classList.contains('hide')).toBe(false); + + click('.js-pause-button'); + + expect(timer.getStatus()).toBe(TimerStatus.PAUSED); + expect(card.querySelector('.js-start-button').classList.contains('hide')).toBe(false); + + click('.js-stop-button'); + + expect(timer.getStatus()).toBe(TimerStatus.STOPPED); + expect(displayedTime()).toBe('00:00:30'); + }); + + it('decrementa com base no relógio, sem depender da frequência do tick', () => { + const now = Date.now(); + click('.js-start-button'); + + expect(timer.tick(now + 500)).toBeNull(); + expect(timer.tick(now + 1000)).toEqual({ second: 29, finished: false }); + expect(displayedTime()).toBe('00:00:29'); + + // pulou 5 segundos de uma vez: o tempo restante acompanha o relógio + expect(timer.tick(now + 6000)).toEqual({ second: 24, finished: false }); + }); + + it('entra em countdown nos últimos 10 segundos e finaliza em zero', () => { + const now = Date.now(); + click('.js-start-button'); + + timer.tick(now + 20000); + expect(timer.getStatus()).toBe(TimerStatus.COUNTDOWN); + + expect(timer.tick(now + 30000)).toEqual({ second: 0, finished: true }); + expect(timer.getStatus()).toBe(TimerStatus.STOPPED); + expect(card.querySelector('.js-start-button').classList.contains('hide')).toBe(false); + }); + + it('mantém o tempo zerado ao terminar e só volta ao ser reiniciado', () => { + card.querySelector('.js-edit-button').click(); + card.querySelector('.js-minute-input').value = '02'; + card.querySelector('.js-seconds-input').value = '00'; + card.querySelector('.js-finish-edit-button').click(); + + expect(timer.getDuration()).toBe(120); + + const now = Date.now(); + click('.js-start-button'); + timer.tick(now + 120000); + + expect(displayedTime()).toBe('00:00:00'); + + jest.advanceTimersByTime(5000); + + expect(displayedTime()).toBe('00:00:00'); + + click('.js-stop-button'); + + expect(displayedTime()).toBe('00:02:00'); + }); + + it('avisa o fim e, um segundo depois, pede a mensagem final', () => { + const onFinish = jest.fn(); + const onFinishedMessage = jest.fn(); + + const otherCard = mountTimerCard(); + const other = TimerController(otherCard, { onFinish, onFinishedMessage }); + + const now = Date.now(); + otherCard.querySelector('.js-start-button').click(); + other.tick(now + 30000); + + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onFinishedMessage).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + + expect(onFinishedMessage).toHaveBeenCalledTimes(1); + + other.destroy(); + }); + + it('não mostra a mensagem final se o timer for reiniciado antes', () => { + const onFinishedMessage = jest.fn(); + + const otherCard = mountTimerCard(); + const other = TimerController(otherCard, { onFinishedMessage }); + + const now = Date.now(); + otherCard.querySelector('.js-start-button').click(); + other.tick(now + 30000); + otherCard.querySelector('.js-start-button').click(); + + jest.advanceTimersByTime(2000); + + expect(onFinishedMessage).not.toHaveBeenCalled(); + + other.destroy(); + }); + + it('limpa a mensagem final do card ao reiniciar', () => { + timer.showFinishedMessage(); + + expect(card.classList.contains('is-finished')).toBe(true); + expect(card.querySelector('.js-timer-countdown').textContent).toBe('ACABOU!'); + + click('.js-start-button'); + + expect(card.classList.contains('is-finished')).toBe(false); + expect(card.querySelector('.js-timer-countdown').classList.contains('hide')).toBe(true); + }); + + it('cancelar a edição restaura o valor anterior', () => { + card.querySelector('.js-edit-button').click(); + card.querySelector('.js-hour-input').value = '05'; + card.querySelector('.js-cancel-edit-button').click(); + + expect(displayedTime()).toBe('00:00:30'); + expect(timer.getDuration()).toBe(DEFAULT_SECONDS); + }); + + it('não permite editar enquanto está rodando', () => { + click('.js-start-button'); + card.querySelector('.js-edit-button').click(); + + expect(timer.getStatus()).toBe(TimerStatus.RUNNING); + expect(card.querySelector('.js-edit-container-stopwatch').classList.contains('hide')).toBe(true); + }); + + it('serializa e restaura nome, duração e tempo restante', () => { + timer.setName('Rodada 1'); + const snapshot = timer.serialize(); + + expect(snapshot).toMatchObject({ name: 'Rodada 1', duration: 30, remaining: 30 }); + + const otherCard = mountTimerCard(); + const other = TimerController(otherCard); + other.restore({ name: 'Rodada 2', duration: 90, remaining: 45, status: TimerStatus.PAUSED }); + + expect(other.getName()).toBe('Rodada 2'); + expect(other.getDuration()).toBe(90); + expect(other.getRemaining()).toBe(45); + expect(other.getStatus()).toBe(TimerStatus.PAUSED); + + other.destroy(); + }); + + it('retoma um timer que estava rodando usando o horário de término salvo', () => { + timer.restore({ + name: 'Ao vivo', + duration: 300, + remaining: 300, + status: TimerStatus.RUNNING, + endsAt: Date.now() + 60000, + }); + + expect(timer.getStatus()).toBe(TimerStatus.RUNNING); + expect(timer.getRemaining()).toBe(60); + }); + + it('restaura um timer que já tinha zerado com a duração cheia', () => { + timer.restore({ name: 'Bloco', duration: 90, remaining: 0, status: 'STOPPED' }); + + expect(timer.getRemaining()).toBe(90); + }); + + it('não retoma um timer cujo horário de término já passou', () => { + timer.restore({ + duration: 300, + remaining: 10, + status: TimerStatus.RUNNING, + endsAt: Date.now() - 5000, + }); + + expect(timer.getStatus()).toBe(TimerStatus.STOPPED); + expect(timer.getRemaining()).toBe(300); + }); +}); diff --git a/asset/javascript/__tests__/TimerApp.test.js b/asset/javascript/__tests__/TimerApp.test.js new file mode 100644 index 0000000..278693c --- /dev/null +++ b/asset/javascript/__tests__/TimerApp.test.js @@ -0,0 +1,181 @@ +import { TimerApp } from '../TimerApp'; +import { STORAGE_KEY } from '../TimerStorage'; +import { mountApp } from '../__mocks__/timerDom'; + +describe('TimerApp', () => { + let body; + let app; + + const selectLayout = total => body.querySelector(`.js-layout-button[data-timers="${total}"]`).click(); + const storedState = () => JSON.parse(window.localStorage.getItem(STORAGE_KEY)); + + beforeEach(() => { + jest.useFakeTimers(); + window.localStorage.clear(); + body = mountApp(); + app = TimerApp(body); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('começa com um único timer e sem controles globais visíveis', () => { + expect(app.getLayout()).toBe(1); + expect(body.querySelectorAll('.js-timer-card')).toHaveLength(1); + expect(body.querySelector('.js-start-all-button').classList.contains('hide')).toBe(true); + }); + + it('troca o layout para 2 e 4 timers e mostra os controles globais', () => { + selectLayout(4); + + expect(body.querySelectorAll('.js-timer-card')).toHaveLength(4); + expect(body.classList.contains('timers-4')).toBe(true); + expect(body.querySelector('.js-start-all-button').classList.contains('hide')).toBe(false); + + selectLayout(2); + + expect(body.querySelectorAll('.js-timer-card')).toHaveLength(2); + expect(body.classList.contains('timers-2')).toBe(true); + expect(body.classList.contains('timers-4')).toBe(false); + }); + + it('preserva os timers existentes ao aumentar a quantidade', () => { + app.getTimers()[0].setName('Palestra'); + + selectLayout(2); + + expect(app.getTimers()[0].getName()).toBe('Palestra'); + expect(app.getTimers()[1].getName()).toBe(''); + }); + + it('iniciar e pausar todos afeta cada timer', () => { + selectLayout(2); + + body.querySelector('.js-start-all-button').click(); + expect(app.getTimers().every(timer => timer.isActive())).toBe(true); + + body.querySelector('.js-pause-all-button').click(); + expect(app.getTimers().some(timer => timer.isActive())).toBe(false); + }); + + it('mantém os timers em sincronia mesmo com durações diferentes', () => { + selectLayout(2); + + const [first, second] = app.getTimers(); + second.restore({ duration: 60, remaining: 60, status: 'STOPPED' }); + + body.querySelector('.js-start-all-button').click(); + jest.advanceTimersByTime(5000); + + expect(first.getRemaining()).toBe(25); + expect(second.getRemaining()).toBe(55); + }); + + it('mostra no título o timer mais próximo de zerar, com o nome', () => { + selectLayout(2); + + const [first, second] = app.getTimers(); + first.setName('Palestra'); + second.setName('Perguntas'); + second.restore({ name: 'Perguntas', duration: 600, remaining: 600, status: 'STOPPED' }); + + body.querySelector('.js-start-all-button').click(); + jest.advanceTimersByTime(1000); + + expect(document.title).toBe('00:00:29 Palestra - Timer '); + }); + + it('com um timer, mostra a contagem em tela cheia e termina em ACABOU!', () => { + const [timer] = app.getTimers(); + const overlayNumber = body.querySelector('.js-countdown-number'); + + timer.restore({ duration: 30, remaining: 3, status: 'PAUSED' }); + timer.start(); + + jest.advanceTimersByTime(1000); + expect(overlayNumber.textContent).toBe('2'); + + jest.advanceTimersByTime(2000); + expect(overlayNumber.textContent).toBe('0'); + + jest.advanceTimersByTime(1000); + expect(overlayNumber.textContent).toBe('ACABOU!'); + expect(body.querySelector('.js-restart-countdown-button').classList.contains('hide')).toBe(false); + + body.querySelector('.js-restart-countdown-button').click(); + + expect(body.querySelector('.js-countdown-container').classList.contains('hide')).toBe(true); + expect(timer.getRemaining()).toBe(30); + }); + + it('com vários timers, o card que zera vira o contador em destaque', () => { + selectLayout(2); + + const [, second] = app.getTimers(); + second.restore({ duration: 30, remaining: 2, status: 'PAUSED' }); + second.start(); + + jest.advanceTimersByTime(1000); + expect(second.element.classList.contains('is-countdown')).toBe(true); + expect(second.element.querySelector('.js-timer-countdown').textContent).toBe('1'); + + jest.advanceTimersByTime(1000); + expect(second.element.querySelector('.js-timer-countdown').textContent).toBe('0'); + + jest.advanceTimersByTime(1000); + expect(second.element.querySelector('.js-timer-countdown').textContent).toBe('ACABOU!'); + expect(second.element.classList.contains('is-finished')).toBe(true); + + second.element.querySelector('.js-start-button').click(); + + expect(second.element.classList.contains('is-finished')).toBe(false); + expect(second.getRemaining()).toBe(30); + }); + + it('persiste layout, tema e timers no localStorage', () => { + selectLayout(2); + app.getTimers()[0].setName('Rodada 1'); + body.querySelector('.js-versus-theme-button').click(); + + const state = storedState(); + + expect(state.layout).toBe(2); + expect(state.theme).toBe('versus'); + expect(state.timers).toHaveLength(2); + expect(state.timers[0].name).toBe('Rodada 1'); + }); + + it('restaura o estado salvo ao recarregar', () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + layout: 4, + theme: 'versus', + timers: [ + { name: 'A', duration: 120, remaining: 90, status: 'PAUSED', endsAt: null }, + { name: 'B', duration: 60, remaining: 60, status: 'STOPPED', endsAt: null }, + { name: 'C', duration: 30, remaining: 30, status: 'STOPPED', endsAt: null }, + { name: 'D', duration: 30, remaining: 30, status: 'STOPPED', endsAt: null }, + ], + }) + ); + + body = mountApp(); + const restored = TimerApp(body); + + expect(restored.getLayout()).toBe(4); + expect(body.classList.contains('versus-theme')).toBe(true); + expect(restored.getTimers().map(timer => timer.getName())).toEqual(['A', 'B', 'C', 'D']); + expect(restored.getTimers()[0].getRemaining()).toBe(90); + }); + + it('ignora um estado salvo inválido', () => { + window.localStorage.setItem(STORAGE_KEY, '{ nao é json'); + + body = mountApp(); + const restored = TimerApp(body); + + expect(restored.getLayout()).toBe(1); + }); +}); diff --git a/asset/javascript/__tests__/TimerIntegration.test.js b/asset/javascript/__tests__/TimerIntegration.test.js deleted file mode 100644 index 3938cf6..0000000 --- a/asset/javascript/__tests__/TimerIntegration.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import TimerController from '../TimeController'; -import { mockElement } from '../__mocks__/domMocks'; - -jest.mock('../TimeUtils'); - -describe('TimerController Integration', () => { - let mockReference; - - beforeEach(() => { - mockReference = { - classList: { - add: jest.fn(), - remove: jest.fn(), - contains: jest.fn(), - toggle: jest.fn(), - }, - querySelector: jest.fn(selector => { - if (selector === '.js-stopwatch-action-buttons') { - return mockElement(); - } - if (selector === '.js-edit-container-stopwatch') { - return mockElement(); - } - if (selector === '.js-countdown-container') { - return mockElement(); - } - return mockElement(); - }), - }; - }); - - it('deve iniciar, pausar e parar o timer corretamente', () => { - const timerController = new TimerController(mockReference); - - const startButton = mockReference - .querySelector('.js-stopwatch-action-buttons') - .querySelector('.js-start-button'); - expect(startButton.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); - - const startHandler = startButton.addEventListener.mock.calls[0][1]; - startHandler(); - - expect(startButton.hideElement).toHaveBeenCalled(); - - const pauseButton = mockReference - .querySelector('.js-stopwatch-action-buttons') - .querySelector('.js-pause-button'); - expect(pauseButton.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); - - const pauseHandler = pauseButton.addEventListener.mock.calls[0][1]; - pauseHandler(); - - expect(pauseButton.hideElement).toHaveBeenCalled(); - }); -}); diff --git a/asset/style/timer.css b/asset/style/timer.css index b701ab5..f88c846 100644 --- a/asset/style/timer.css +++ b/asset/style/timer.css @@ -8,8 +8,40 @@ --button-icon-filter: inherit; --button-border: 1px solid #fff; --primary-dark-color: #1ea97a; + --clock-font-size: min(13vw, 36vh); + --timer-name-font-size: min(3vw, 32px); + --timers-columns: 1; + --timers-rows: 1; } +body.timers-2 { + --clock-font-size: min(6.6vw, 30vh); + --timer-name-font-size: min(2vw, 26px); + --timers-columns: 2; + --timers-rows: 1; +} + +body.timers-4 { + --clock-font-size: min(6.6vw, 16vh); + --timer-name-font-size: min(1.6vw, 22px); + --timers-columns: 2; + --timers-rows: 2; +} + +@media screen and (max-width: 768px) { + body.timers-2, + body.timers-4 { + --clock-font-size: min(16vw, 18vh); + --timer-name-font-size: 16px; + --timers-columns: 1; + } + body.timers-2 { + --timers-rows: 2; + } + body.timers-4 { + --timers-rows: 4; + } +} .versus-theme { --primary-bg-color: #ED5026; --primary-text-color: #FFFFFF; @@ -19,10 +51,6 @@ --button-icon-filter: brightness(100); --button-border: 1px solid #fff; } -.versus-theme .timer-logo { - display: block; - width: 380px; -} * { font-family: "Orbitron", sans-serif; @@ -37,10 +65,11 @@ html { flex-direction: column; justify-content: center; align-items: center; - gap: 48px; + gap: 16px; margin: 0; height: 100dvh; width: 100dvw; + overflow: hidden; } body::selection, html::selection { @@ -62,6 +91,82 @@ html.inverted .input-stopwatch-container input { color: var(--primary-bg-color); } +.timers-grid { + display: grid; + grid-template-columns: repeat(var(--timers-columns), minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: 32px 96px; + align-items: stretch; + justify-items: stretch; + flex: 1; + min-height: 0; + width: 100%; + padding: 32px 64px 96px; +} + +.timer-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + width: 100%; + min-height: 0; + overflow: hidden; + padding: 16px 24px; + border-radius: 16px; + transition: background-color 0.25s ease, color 0.25s ease; +} +.timer-card.inverted { + background-color: var(--secondary-bg-color); + color: var(--primary-bg-color); +} +.timer-card.inverted .input-stopwatch-container input, +.timer-card.inverted .timer-name { + color: var(--primary-bg-color); +} +.timer-card.inverted .action-button-container button { + border-color: var(--primary-bg-color); +} + +.timer-countdown { + font-size: calc(var(--clock-font-size) * 1.7); + line-height: 1; + transition: color 0.25s ease; +} +.timer-countdown.is-message { + font-size: calc(var(--clock-font-size) * 0.75); + letter-spacing: 0.08em; +} + +.timer-card.is-countdown { + box-shadow: 0 0 0 2px currentColor inset; +} + +.timer-name { + width: 100%; + max-width: 100%; + margin: 0; + padding: 4px 8px; + border: none; + border-radius: 8px; + background: none; + outline: none; + color: inherit; + font-size: var(--timer-name-font-size); + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; + transition: all 0.3s ease; +} +.timer-name::placeholder { + color: currentColor; + opacity: 0.4; +} +.timer-name:focus { + background: rgba(255, 255, 255, 0.1); +} + .countdown-container { width: 100%; height: 100%; @@ -74,9 +179,14 @@ html.inverted .input-stopwatch-container input { transition: all 0.5s ease; } .countdown-container .countdown-number { - font-size: 202px; + font-size: min(30vw, 60vh); + line-height: 1; transition: all 0.5s ease; } +.countdown-container .countdown-number.is-message { + font-size: min(12vw, 18vh); + letter-spacing: 0.08em; +} .countdown-container.odd { background: var(--secondary-bg-color); color: var(--secondary-text-color); @@ -98,18 +208,15 @@ html.inverted .input-stopwatch-container input { display: flex; justify-content: center; align-items: center; - transition: all 0.5s ease; - font-size: 112px; -} -@media screen and (max-width: 768px) { - .input-stopwatch-container { - font-size: 56px; - } + transition: color 0.5s ease; + font-size: var(--clock-font-size); + line-height: 1.1; } .input-stopwatch-container input { - width: auto; - min-width: 1px; + width: 2ch; + min-width: 2ch; max-width: 2ch; + font-variant-numeric: tabular-nums; margin: 0; padding: 0; color: var(--primary-text-color); @@ -166,12 +273,50 @@ button.as-icon:active, button.as-icon:focus, button.as-icon:focus-visible, butto outline: none; } +.global-controls { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + align-items: center; + z-index: 5; +} + .hide { display: none !important; } -.timer-logo { - display: none; +.theme-dropdown-label { + padding: 0 12px; + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.layout-options { + display: flex; + gap: 8px; + padding: 0 4px; +} +.layout-options .layout-button { + flex: 1; + padding: 8px 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: transparent; + color: white; + font-size: 14px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; +} +.layout-options .layout-button:hover { + background: rgba(255, 255, 255, 0.1); +} +.layout-options .layout-button.is-active { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.6); } .theme-menu-container { diff --git a/asset/style/timer.css.map b/asset/style/timer.css.map index 1b0ada7..10faf41 100644 --- a/asset/style/timer.css.map +++ b/asset/style/timer.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["timer.scss"],"names":[],"mappings":"AAAQ;AAER;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;;AAIR;EACI;EACA;;;AAGJ;AAAA;EAEI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;AAAA;EACI;EACA;;AAGJ;AAAA;EACI;EACA;;AAGJ;AAAA;EACI;EACA;;AAEA;AAAA;EACI;;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EARJ;IASQ;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAEI;EACA;;AAGJ;EACI;EACA;EACA;;;AAKZ;EACI;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EAII;EACA;EACA;;;AAIR;EACI;;;AAGJ;EACI;;;AAGJ;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EAEA;EACA;EACA;EAEA;EAEA;EACA;EACA;EAEA;EAEA;;AAEA;EACI;EACA;EAEA;EACA;EAEA;EAEA;EACA;EACA;EAEA;;AAEA;EACI","file":"timer.css"} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["timer.scss"],"names":[],"mappings":"AAAQ;AAER;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;;;AAGJ;EACI;AAAA;IAEI;IACA;IACA;;EAGJ;IAAgB;;EAChB;IAAgB;;;AAGpB;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;;;AAGJ;AAAA;EAEI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;AAAA;EACI;EACA;;AAGJ;AAAA;EACI;EACA;;AAGJ;AAAA;EACI;EACA;;AAEA;AAAA;EACI;;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAEA;AAAA;EAEI;;AAGJ;EACI;;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;EACA;;;AAIR;EACI;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;;;AAMR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAEI;EACA;;AAGJ;EACI;EACA;EACA;;;AAKZ;EACI;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EAII;EACA;EACA;;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;;;AAGJ;EACI;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EAEA;EACA;EACA;EAEA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;;AAKZ;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EAEA;EACA;EACA;EAEA;EAEA;EACA;EACA;EAEA;EAEA;;AAEA;EACI;EACA;EAEA;EACA;EAEA;EAEA;EACA;EACA;EAEA;;AAEA;EACI","file":"timer.css"} \ No newline at end of file diff --git a/asset/style/timer.scss b/asset/style/timer.scss index 986338f..04c4547 100644 --- a/asset/style/timer.scss +++ b/asset/style/timer.scss @@ -9,6 +9,36 @@ --button-icon-filter: inherit; --button-border: 1px solid #fff; --primary-dark-color: #1ea97a; + --clock-font-size: min(13vw, 36vh); + --timer-name-font-size: min(3vw, 32px); + --timers-columns: 1; + --timers-rows: 1; +} + +body.timers-2 { + --clock-font-size: min(6.6vw, 30vh); + --timer-name-font-size: min(2vw, 26px); + --timers-columns: 2; + --timers-rows: 1; +} + +body.timers-4 { + --clock-font-size: min(6.6vw, 16vh); + --timer-name-font-size: min(1.6vw, 22px); + --timers-columns: 2; + --timers-rows: 2; +} + +@media screen and (max-width: 768px) { + body.timers-2, + body.timers-4 { + --clock-font-size: min(16vw, 18vh); + --timer-name-font-size: 16px; + --timers-columns: 1; + } + + body.timers-2 { --timers-rows: 2; } + body.timers-4 { --timers-rows: 4; } } .versus-theme { @@ -19,11 +49,6 @@ --button-background-color: transparent; --button-icon-filter: brightness(100); --button-border: 1px solid #fff; - - .timer-logo { - display: block; - width: 380px; - } } * { @@ -39,10 +64,11 @@ html { flex-direction: column; justify-content: center; align-items: center; - gap: 48px; + gap: 16px; margin: 0; height: 100dvh; width: 100dvw; + overflow: hidden; &::selection { background: var(--secondary-bg-color); @@ -64,6 +90,91 @@ html { } } +.timers-grid { + display: grid; + grid-template-columns: repeat(var(--timers-columns), minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: 32px 96px; + align-items: stretch; + justify-items: stretch; + flex: 1; + min-height: 0; + width: 100%; + padding: 32px 64px 96px; +} + +.timer-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + width: 100%; + min-height: 0; + overflow: hidden; + padding: 16px 24px; + border-radius: 16px; + transition: background-color 0.25s ease, color 0.25s ease; + + &.inverted { + background-color: var(--secondary-bg-color); + color: var(--primary-bg-color); + + .input-stopwatch-container input, + .timer-name { + color: var(--primary-bg-color); + } + + .action-button-container button { + border-color: var(--primary-bg-color); + } + } +} + +.timer-countdown { + font-size: calc(var(--clock-font-size) * 1.7); + line-height: 1; + transition: color 0.25s ease; + + &.is-message { + font-size: calc(var(--clock-font-size) * 0.75); + letter-spacing: 0.08em; + } +} + +.timer-card.is-countdown { + box-shadow: 0 0 0 2px currentColor inset; +} + +.timer-name { + width: 100%; + max-width: 100%; + margin: 0; + padding: 4px 8px; + border: none; + border-radius: 8px; + background: none; + outline: none; + + color: inherit; + font-size: var(--timer-name-font-size); + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; + transition: all 0.3s ease; + + &::placeholder { + color: currentColor; + opacity: 0.4; + } + + &:focus { + background: rgba(255, 255, 255, 0.1); + } +} + + + .countdown-container { width: 100%; height: 100%; @@ -76,8 +187,14 @@ html { transition: all 0.5s ease; .countdown-number { - font-size: 202px; + font-size: min(30vw, 60vh); + line-height: 1; transition: all 0.5s ease; + + &.is-message { + font-size: min(12vw, 18vh); + letter-spacing: 0.08em; + } } &.odd { @@ -105,17 +222,15 @@ html { display: flex; justify-content: center; align-items: center; - transition: all 0.5s ease; - font-size: 112px; - - @media screen and (max-width: 768px) { - font-size: 56px; - } + transition: color 0.5s ease; + font-size: var(--clock-font-size); + line-height: 1.1; input { - width: auto; - min-width: 1px; + width: 2ch; + min-width: 2ch; max-width: 2ch; + font-variant-numeric: tabular-nums; margin: 0; padding: 0; color: var(--primary-text-color); @@ -182,12 +297,55 @@ button.as-icon { } } +.global-controls { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + align-items: center; + z-index: 5; +} + .hide { display: none !important; } -.timer-logo { - display: none; +.theme-dropdown-label { + padding: 0 12px; + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.layout-options { + display: flex; + gap: 8px; + padding: 0 4px; + + .layout-button { + flex: 1; + padding: 8px 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: transparent; + + color: white; + font-size: 14px; + text-align: center; + + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + &.is-active { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.6); + } + } } .theme-menu-container { diff --git a/index.html b/index.html index 3bfa7ea..1b4821e 100644 --- a/index.html +++ b/index.html @@ -9,19 +9,71 @@ Timer <Codecon> - - - -
- - : - - : - + + + +
+ +
+ + + + +
@@ -30,6 +82,16 @@
+ Timers + +
+ + + +
+ + Tema + @@ -40,45 +102,18 @@
-
- - - - - - -
- -
- - -
-
+
- + diff --git a/package.json b/package.json index d10ddf6..3712c7e 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "^.+\\.(wav|mp3)$": "/asset/javascript/__mocks__/fileMock.js" }, "setupFiles": [ - "/asset/javascript/__mocks__/audioMock.js" + "/asset/javascript/__mocks__/audioMock.js", + "/asset/javascript/__mocks__/browserApiMocks.js" ] }, "targets": {