diff --git a/package.json b/package.json index f60a186..b394c96 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "type": "module", "jest": { "testEnvironment": "jsdom", + "setupFilesAfterEnv": [ + "/src/setupTests.js" + ], "moduleNameMapper": { "^.+\\.svg$": "jest-svg-transformer", "^.+\\.(css|less|scss)$": "identity-obj-proxy" diff --git a/src/App.jsx b/src/App.jsx index 6eb8bb5..7410b86 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import classNames from 'classnames'; import './styles/App.css'; import { DEFAULT_LIMIT_SEC, DEFAULT_WARNING_SEC } from './constants'; @@ -12,15 +12,30 @@ const App = () => { const [timeWarning, setTimeWarning] = useState(DEFAULT_WARNING_SEC); useEffect(() => { - if (timerStarted) { - const timeout = setTimeout(() => { - setTime(timer(timeElapsed)); - }, 1000); - return () => clearTimeout(timeout); - } else { - setTime(timeElapsed); - } - }, [setTime, timeElapsed, timerStarted]); + if (!timerStarted) return; + + const timeout = setTimeout(() => { + setTime(timer(timeElapsed)); + }, 1000); + + return () => clearTimeout(timeout); + }, [timeElapsed, timerStarted]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyPress = (event) => { + if (event.code === 'Space') { + event.preventDefault(); + setTimerStatus((prev) => !prev); + } else if (event.code === 'KeyR') { + event.preventDefault(); + setTime(0); + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, []); const handleLimitUpdate = useCallback((value) => { setTimeLimit(value); @@ -30,73 +45,97 @@ const App = () => { setTimeWarning(value); }, []); - const renderControls = useMemo(() => { - return ( -
- - + const handleReset = useCallback(() => { + setTime(0); + }, []); + + const handleToggleTimer = useCallback(() => { + setTimerStatus((prev) => !prev); + }, []); + + const isWarning = timeElapsed > timeLimit - timeWarning; + const isOverLimit = timeElapsed > timeLimit; + + return ( +
+
+
+ {formatTime(timeElapsed)} +
+ +
+ + +
- ); - }, [timerStarted]); - const renderSettings = useMemo(() => { - return (
- + + />
+ + Timer stops when limit is reached +
- + /> +
+ + Warning shown before time limit + +
+
+
+
+ + Keyboard shortcuts: Space to start/stop, R to reset +
- ); - }, [handleLimitUpdate, handleWarningUpdate, timeLimit, timeWarning]); - - return ( -
-
timeLimit - timeWarning, - stop: timeElapsed > timeLimit, - })} - > -
{formatTime(timeElapsed)}
- {renderControls} -
- {renderSettings}
); }; diff --git a/src/components/time-input.jsx b/src/components/time-input.jsx index f3f1c77..80d177f 100644 --- a/src/components/time-input.jsx +++ b/src/components/time-input.jsx @@ -12,13 +12,26 @@ export const TimeInput = ({ onChange, }) => { const [value, setValue] = useState(formatTime(placeholderSec)); - const handleChange = useCallback( - ({ target }) => { - setValue(formatInputTime(formatTime(target.value))); - onChange(convertToSeconds(target.value)); - }, - [onChange] - ); + const [isEditing, setIsEditing] = useState(false); + + const handleChange = useCallback(({ target }) => { + // While editing, just store the raw value (digits only) + setValue(target.value); + }, []); + + const handleFocus = useCallback(() => { + setIsEditing(true); + // Clear the formatted value to let user type fresh + setValue(''); + }, []); + + const handleBlur = useCallback(() => { + setIsEditing(false); + // Format the value when done editing + const formattedValue = formatInputTime(value); + setValue(formattedValue); + onChange(convertToSeconds(formattedValue)); + }, [value, onChange]); return ( ); diff --git a/src/components/time-input.test.js b/src/components/time-input.test.js index 6398fe1..7ad3d4a 100644 --- a/src/components/time-input.test.js +++ b/src/components/time-input.test.js @@ -7,10 +7,10 @@ const setup = () => { console.log('change', event)} value={90} - > + >, ); const input = screen.getByLabelText('time'); return { @@ -21,18 +21,25 @@ const setup = () => { test('Input should set display value', () => { const { input } = setup(); + + // Focus, type, then blur to trigger formatting + fireEvent.focus(input); fireEvent.change(input, { target: { value: '23' } }); + fireEvent.blur(input); expect(input.value).toBe('0:23'); + fireEvent.focus(input); fireEvent.change(input, { target: { value: 'y15efg' } }); + fireEvent.blur(input); expect(input.value).toBe('0:15'); - fireEvent.change(input, { target: { value: '8' } }); - fireEvent.change(input, { target: { value: '80' } }); + fireEvent.focus(input); fireEvent.change(input, { target: { value: '800' } }); + fireEvent.blur(input); expect(input.value).toBe('8:00'); - fireEvent.change(input, { target: { value: '9' } }); + fireEvent.focus(input); fireEvent.change(input, { target: { value: '90' } }); + fireEvent.blur(input); expect(input.value).toBe('1:30'); }); diff --git a/src/lib/time-utils.js b/src/lib/time-utils.js index 169c093..63dd4cf 100644 --- a/src/lib/time-utils.js +++ b/src/lib/time-utils.js @@ -1,42 +1,109 @@ +/** + * Increments the timer by one second + * @param {number} secondsElapsed - The current number of seconds elapsed + * @returns {number} The incremented seconds value + */ export function timer(secondsElapsed) { - if (!secondsElapsed) return 1; + if (!secondsElapsed || typeof secondsElapsed !== 'number') return 1; return secondsElapsed + 1; } +/** + * Formats seconds into MM:SS format + * @param {number|string} elapsedSeconds - The number of seconds to format, or already formatted string + * @returns {string} Time formatted as MM:SS + */ export function formatTime(elapsedSeconds) { if (!elapsedSeconds) return '0:00'; - if (elapsedSeconds.length > 2) return elapsedSeconds; - if (isNaN(elapsedSeconds)) return elapsedSeconds; + + // If already formatted as a string, return as-is + if (typeof elapsedSeconds === 'string') return elapsedSeconds; + + // If not a valid number, return the value as-is + if (typeof elapsedSeconds !== 'number' || isNaN(elapsedSeconds)) { + return String(elapsedSeconds); + } + const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; - const displaySeconds = - seconds < 10 ? `0${seconds.toString()}` : seconds.toString(); + const displaySeconds = seconds < 10 ? `0${seconds}` : `${seconds}`; return `${minutes}:${displaySeconds}`; } +/** + * Formats user input into MM:SS time format + * Last 2 digits are treated as seconds, remaining digits as minutes + * e.g., "130" = "1:30", "800" = "8:00", "90" = "1:30" + * @param {string} inputTime - The raw user input + * @returns {string} Formatted time as MM:SS + */ export function formatInputTime(inputTime) { if (!inputTime) return '0:00'; - const removeNonnumeric = inputTime.replace(/\D/g, ''); - const numberString = removeNonnumeric.replace(/\b0+/g, ''); + // Remove all non-numeric characters + const numericOnly = inputTime.replace(/\D/g, ''); + // Remove leading zeros + const numberString = numericOnly.replace(/^0+/, '') || '0'; + + if (numberString.length === 0) { + return '0:00'; + } if (numberString.length === 1) { + // Single digit: treat as seconds return `0:0${numberString}`; } + if (numberString.length === 2) { + // Two digits: treat as seconds, convert if >= 60 + const seconds = parseInt(numberString, 10); + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + const displaySeconds = + remainingSeconds < 10 ? `0${remainingSeconds}` : `${remainingSeconds}`; + return `${minutes}:${displaySeconds}`; + } return `0:${numberString}`; } - // add colon - const timeArr = String(numberString).split(''); - timeArr.splice(timeArr.length - 2, 0, ':'); - return timeArr.join(''); + // 3+ digits: last 2 are seconds, rest are minutes + const secondsPart = numberString.slice(-2); + const minutesPart = numberString.slice(0, -2); + + let minutes = parseInt(minutesPart, 10); + let seconds = parseInt(secondsPart, 10); + + // If seconds >= 60, convert to minutes + if (seconds >= 60) { + minutes += Math.floor(seconds / 60); + seconds = seconds % 60; + } + + const displaySeconds = seconds < 10 ? `0${seconds}` : `${seconds}`; + return `${minutes}:${displaySeconds}`; } +/** + * Converts formatted time (MM:SS) to total seconds + * @param {string|number} formattedTime - Time in MM:SS format or raw seconds + * @returns {number} Total seconds + */ export function convertToSeconds(formattedTime) { if (!formattedTime) return 0; - if (!isNaN(formattedTime)) return formattedTime; - const value = formattedTime.split(':'); - return Number(value[0]) * 60 + Number(value[1]); + + // If already a number, return it + if (typeof formattedTime === 'number') return formattedTime; + + // If not a valid number as string, try to parse as MM:SS + if (!isNaN(formattedTime)) return Number(formattedTime); + + const parts = String(formattedTime).split(':'); + if (parts.length !== 2) return 0; + + const minutes = Number(parts[0]) || 0; + const seconds = Number(parts[1]) || 0; + + return minutes * 60 + seconds; } diff --git a/src/lib/time-utils.test.js b/src/lib/time-utils.test.js index 8a4eb2c..1569fd7 100644 --- a/src/lib/time-utils.test.js +++ b/src/lib/time-utils.test.js @@ -24,7 +24,8 @@ it('formats input time', () => { expect(formatInputTime('3')).toBe('0:03'); expect(formatInputTime('30')).toBe('0:30'); expect(formatInputTime('130')).toBe('1:30'); - expect(formatInputTime('1:30')).toBe('1:30'); + expect(formatInputTime('800')).toBe('8:00'); + expect(formatInputTime('90')).toBe('1:30'); expect(formatInputTime('y15efg')).toBe('0:15'); expect(formatInputTime('y300efg')).toBe('3:00'); }); diff --git a/src/setupTests.js b/src/setupTests.js index 8f2609b..a416ad1 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -3,3 +3,23 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; +import * as React from 'react'; + +// Suppress ReactDOMTestUtils.act deprecation warning +// Use React.act instead of ReactDOMTestUtils.act +const originalError = console.error; +beforeAll(() => { + console.error = (...args) => { + if ( + typeof args[0] === 'string' && + args[0].includes('ReactDOMTestUtils.act') + ) { + return; + } + originalError.call(console, ...args); + }; +}); + +afterAll(() => { + console.error = originalError; +});