diff --git a/App.tsx b/App.tsx
index 0329d0c..dc01a8e 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,11 +1,11 @@
-import { StatusBar } from 'expo-status-bar';
-import { StyleSheet, Text, View } from 'react-native';
+import React from 'react';
+import { StyleSheet, View } from 'react-native';
+import StopWatch from './src/StopWatch';
export default function App() {
return (
- Open up App.tsx to start working on your app!
-
+
);
}
diff --git a/README.md b/README.md
index 6b8b50e..08de74e 100644
--- a/README.md
+++ b/README.md
@@ -1,84 +1,24 @@
-# Technical Instructions
-1. Fork this repo to your local Github account.
-2. Create a new branch to complete all your work in.
-3. Test your work using the provided tests
-4. Create a Pull Request against the Shopify Main branch when you're done and all tests are passing
+# Tom Miller's Stopwatch
-# Project Overview
-The goal of this project is to implement a stopwatch application using React Native and TypeScript. The stopwatch should have the following functionality:
+## Features
-- Start the stopwatch to begin counting time.
-- Stop the stopwatch to pause the timer.
-- Displays Laps when a button is pressed.
-- Reset the stopwatch to zero.
+My stopwatch app has the following features:
+- Start button to start/resume the timer and a stop button to pause it.
+- Reset button to reset the times and laps to 0.
+- Lap button to record the laps easily along with minimum and maximum laps colored in red and green respectively to identify your slowest and fastest times easily.
-You will be provided with a basic project structure that includes the necessary files and dependencies. Your task is to write the code to implement the stopwatch functionality and ensure that it works correctly.
-## Project Setup
-To get started with the project, follow these steps:
+
-1. Clone the project repository to your local development environment.
-2. Install the required dependencies by running npm install in the project directory.
-3. Familiarize yourself with the project structure. The main files you will be working with are:
- - /App.tsx: The main component that renders the stopwatch and handles its functionality.
- - src/Stopwatch.tsx: A separate component that represents the stopwatch display.
- - src/StopwatchButton.tsx: A separate component that represents the start, stop, and reset buttons.
-4. Review the existing code in the above files to understand the initial structure and component hierarchy.
+## Tests
-## Project Goals
-Your specific goals for this project are as follows:
+I added a few extra tests along with the original ones, such as a test to see that the maximum and minimum laps are colored correctly
+and a test to ensure you can't add laps while paused.
-1. Implement the stopwatch functionality:
- - The stopwatch should start counting when the user clicks the start button.
- - The stopwatch should stop counting when the user clicks the stop button.
- - The stopwatch should reset to zero when the user clicks the reset button.
- - The stopwatch should record and display laps when user clicks the lap button.
+## Design Choices
-2. Ensure code quality:
- - Write clean, well-structured, and maintainable code.
- - Follow best practices and adhere to the React and TypeScript coding conventions.
- - Pay attention to code readability, modularity, and performance.
-
-3. Test your code:
- - Run the application and test the stopwatch functionality to ensure it works correctly.
- - Verify that the stopwatch starts, stops, resets, and records laps as expected.
-
-4. Code documentation:
- - Document your code by adding comments and explanatory notes where necessary.
- - Provide clear explanations of the implemented functionality and any important details.
-
-5. Version control:
- - Use Git for version control. Commit your changes regularly and push them to a branch in your forked repository.
-
- 6. Create a Pull Request:
- - Once you have completed the project goals, create a pull request to merge your changes into the main repository.
- - Provide a clear description of the changes made and any relevant information for the code review.
-
-## Getting Started
-To start working on the project, follow these steps:
-
-1. Clone the repository to your local development environment.
-
-2. Install the required dependencies by running npm install in the project directory.
-
-3. Open the project in your preferred code editor.
-
-4. Review the existing code in the src directory to understand the initial structure and component hierarchy.
-
-5. Implement the stopwatch functionality by modifying the necessary components (App.tsx, Stopwatch.tsx, StopwatchButton.tsx).
-
-6. Run the application using npm start and test the stopwatch functionality.
-
-7. Commit your changes regularly and push them to a branch in your forked repository.
-
-8. Once you have completed the project goals, create a pull request to merge your changes into the main repository.
-
-## Resources
-Here are some resources that may be helpful during your work on this project:
-
-- [TypeScript Documentation](https://www.typescriptlang.org/docs/) - Official documentation for TypeScript, offering guidance on TypeScript features and usage.
-
-- [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) - Explore React Testing Library, a popular testing library for React applications.
+- I created a useStopWatch hook to help separate all the logic of the stopwatch and keep all components clean and simple.
+- I used Date objects instead of setInterval due to it being less accurate, especially for something where every second is important like a stopwatch.
diff --git a/src/StopWatch.tsx b/src/StopWatch.tsx
index 5c7eb74..4e7560f 100644
--- a/src/StopWatch.tsx
+++ b/src/StopWatch.tsx
@@ -1,8 +1,118 @@
-import { View } from 'react-native';
+import React from 'react';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import StopwatchButton from './StopWatchButton';
+import { useStopWatch } from './hooks/useStopWatch';
-export default function StopWatch() {
+function formatNumber(num: number) {
+ return String(num).padStart(2, '0');
+}
+
+const formatTime = (milliseconds: number) => {
+ const hours = formatNumber(Math.floor(milliseconds / 3600000));
+ const minutes = formatNumber(Math.floor((milliseconds % 3600000) / 60000));
+ const seconds = formatNumber(Math.floor((milliseconds % 60000) / 1000));
+ const centiseconds = formatNumber(Math.floor((milliseconds % 1000) / 10));
+
+ return `${hours}:${minutes}:${seconds}.${centiseconds}`;
+};
+
+export const lapColors = {
+ default: 'black',
+ minLap: 'red',
+ maxLap: '#5ab068'
+};
+
+export default function Stopwatch() {
+
+ const stopwatch = useStopWatch();
return (
-
+
+
+
+ {formatTime(stopwatch.milliseconds)}
+
+
+
+
+ {stopwatch.laps.slice().reverse().map((lap, index) => {
+ let lapColor = lapColors.default;
+
+ if (stopwatch.laps.length >= 3) {
+ if (lap === stopwatch.minLapTime) {
+ lapColor = lapColors.minLap;
+ }
+ else if (lap === stopwatch.maxLapTime) {
+ lapColor = lapColors.maxLap;
+ }
+ }
+
+ return (
+
+
+
+ Lap {stopwatch.laps.length - index}
+
+
+ {formatTime(lap)}
+
+
+
+
+ );
+ })}
+
+
);
-}
\ No newline at end of file
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ contentContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ lapContentContainer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ width:'75%',
+ },
+ time: {
+ fontSize: 56,
+ fontWeight: '500',
+ marginBottom: 20,
+ minWidth:320,
+ },
+ lapScrollView: {
+ maxHeight: 210,
+ width: '100%',
+ marginTop:'10%',
+ },
+ lapScrollViewContent: {
+ alignItems: 'center',
+ },
+ lap: {
+ fontSize: 18,
+ marginTop: 12,
+ },
+ divider: {
+ borderBottomColor: 'lightgray',
+ borderBottomWidth: 1,
+ width: '75%',
+ marginTop:6,
+ }
+});
diff --git a/src/StopWatchButton.tsx b/src/StopWatchButton.tsx
index 8768555..d6a9a85 100644
--- a/src/StopWatchButton.tsx
+++ b/src/StopWatchButton.tsx
@@ -1,8 +1,22 @@
-import { View } from 'react-native';
-export default function StopWatchButton() {
+import React from 'react';
+import { Button, View } from 'react-native';
+
+export default function StopwatchButton({ onResume, onReset, onLap, onPause, isPaused }: {
+ onReset: () => void;
+ onLap: () => void;
+ onPause: () => void;
+ onResume: () => void;
+ isPaused: boolean;
+}) {
+
return (
-
+
+
+
+
+
);
-}
\ No newline at end of file
+}
+
diff --git a/src/components/StopWatch.tsx b/src/components/StopWatch.tsx
new file mode 100644
index 0000000..2e52aef
--- /dev/null
+++ b/src/components/StopWatch.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import StopwatchButton from './StopWatchButton';
+import { useStopWatch } from '../hooks/useStopWatch';
+
+function formatNumber(num: number) {
+ return String(num).padStart(2, '0');
+}
+
+const formatTime = (milliseconds: number) => {
+ const hours = formatNumber(Math.floor(milliseconds / 3600000));
+ const minutes = formatNumber(Math.floor((milliseconds % 3600000) / 60000));
+ const seconds = formatNumber(Math.floor((milliseconds % 60000) / 1000));
+ const centiseconds = formatNumber(Math.floor((milliseconds % 1000) / 10));
+
+ return `${hours}:${minutes}:${seconds}.${centiseconds}`;
+};
+
+export const lapColors = {
+ default: 'black',
+ minLap: 'red',
+ maxLap: '#5ab068'
+};
+
+export default function Stopwatch() {
+
+ const stopwatch = useStopWatch();
+ return (
+
+
+
+ {formatTime(stopwatch.milliseconds)}
+
+
+
+
+ {stopwatch.laps.slice().reverse().map((lap, index) => {
+ let lapColor = lapColors.default;
+
+ if (stopwatch.laps.length >= 3) {
+ if (lap === stopwatch.minLapTime) {
+ lapColor = lapColors.minLap;
+ }
+ else if (lap === stopwatch.maxLapTime) {
+ lapColor = lapColors.maxLap;
+ }
+ }
+
+ return (
+
+
+
+ Lap {stopwatch.laps.length - index}
+
+
+ {formatTime(lap)}
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ contentContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ lapContentContainer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ width:'75%',
+ },
+ time: {
+ fontSize: 56,
+ fontWeight: '500',
+ marginBottom: 20,
+ minWidth:320,
+ },
+ lapScrollView: {
+ maxHeight: 210,
+ width: '100%',
+ marginTop:'10%',
+ },
+ lapScrollViewContent: {
+ alignItems: 'center',
+ },
+ lap: {
+ fontSize: 18,
+ marginTop: 12,
+ },
+ divider: {
+ borderBottomColor: 'lightgray',
+ borderBottomWidth: 1,
+ width: '75%',
+ marginTop:6,
+ }
+});
diff --git a/src/components/StopWatchButton.tsx b/src/components/StopWatchButton.tsx
new file mode 100644
index 0000000..d6a9a85
--- /dev/null
+++ b/src/components/StopWatchButton.tsx
@@ -0,0 +1,22 @@
+
+import React from 'react';
+import { Button, View } from 'react-native';
+
+export default function StopwatchButton({ onResume, onReset, onLap, onPause, isPaused }: {
+ onReset: () => void;
+ onLap: () => void;
+ onPause: () => void;
+ onResume: () => void;
+ isPaused: boolean;
+}) {
+
+ return (
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/hooks/useStopWatch.tsx b/src/hooks/useStopWatch.tsx
new file mode 100644
index 0000000..cae4b9b
--- /dev/null
+++ b/src/hooks/useStopWatch.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+
+export interface Stopwatch {
+ milliseconds: number;
+ laps: number[];
+ minLapTime: number | null;
+ maxLapTime: number | null;
+ isPaused: boolean;
+ resume: () => void;
+ pause: () => void;
+ reset: () => void;
+ lap: () => void;
+}
+
+export function useStopWatch(): Stopwatch {
+ const [milliseconds, setMilliseconds] = useState(0);
+ const [isPaused, setIsPaused] = useState(true);
+ const [laps, setLaps] = useState([]);
+
+ const lastTime = useRef(Date.now());
+ const accumulatedTime = useRef(0);
+
+ const minLapTime = useRef(null);
+ const maxLapTime = useRef(null);
+
+ // just so we don't recompute this each time for no reason
+ const totalLaps = useMemo(
+ () => laps.reduce((total, lap) => total + lap, 0),
+ [laps],
+ );
+
+ function updateMinAndMaxLapTimes(newLap: number) {
+ if (minLapTime.current === null || newLap < minLapTime.current) {
+ minLapTime.current = newLap;
+ }
+ if (maxLapTime.current === null || newLap > maxLapTime.current) {
+ maxLapTime.current = newLap;
+ }
+ }
+
+ function resume() {
+ if (!isPaused) return;
+ setIsPaused(false);
+ lastTime.current = Date.now();
+ }
+
+ function pause() {
+ if (isPaused) return;
+ setIsPaused(true);
+ const currentTime = Date.now();
+ accumulatedTime.current += currentTime - lastTime.current;
+ lastTime.current = currentTime;
+ }
+
+ function reset() {
+ setIsPaused(true);
+ lastTime.current = Date.now();
+ setMilliseconds(0);
+ setLaps([]);
+ minLapTime.current = null;
+ maxLapTime.current = null;
+ accumulatedTime.current = 0;
+ }
+
+ function lap() {
+ if(milliseconds === 0 || isPaused) return;
+ const lapTime = milliseconds - totalLaps;
+ setLaps((laps) => {
+ updateMinAndMaxLapTimes(lapTime);
+ return [...laps, lapTime];
+ });
+ }
+
+ useEffect(() => {
+ if (isPaused) return;
+
+ const interval = setInterval(() => {
+ const currentTime = Date.now();
+ const timePassed = currentTime - lastTime.current;
+ setMilliseconds(timePassed+accumulatedTime.current);
+ }, 10);
+
+ return () => clearInterval(interval);
+ }, [isPaused]);
+
+ return {
+ milliseconds,
+ laps,
+ isPaused,
+ minLapTime: minLapTime.current,
+ maxLapTime: maxLapTime.current,
+ resume,
+ pause,
+ reset,
+ lap,
+ };
+}
diff --git a/tests/Stopwatch.test.js b/tests/Stopwatch.test.js
index d5e9f1f..c6d7fda 100644
--- a/tests/Stopwatch.test.js
+++ b/tests/Stopwatch.test.js
@@ -1,55 +1,133 @@
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import { render, fireEvent, act, waitFor } from '@testing-library/react-native';
import Stopwatch from '../src/Stopwatch';
+import { lapColors } from '../src/Stopwatch';
+
+jest.useFakeTimers();
describe('Stopwatch', () => {
test('renders initial state correctly', () => {
const { getByText, queryByTestId } = render();
- expect(getByText('00:00:00')).toBeTruthy();
- expect(queryByTestId('lap-list')).toBeNull();
+ expect(getByText('00:00:00.00')).toBeTruthy();
+ const lapOneTime = queryByTestId('time-0');
+ expect(lapOneTime).toBeNull();
});
test('starts and stops the stopwatch', () => {
const { getByText, queryByText } = render();
fireEvent.press(getByText('Start'));
- expect(queryByText(/(\d{2}:){2}\d{2}/)).toBeTruthy();
+ expect(queryByText(/(\d{2}:){2}\d{2}\.\d{2}/)).toBeTruthy();
fireEvent.press(getByText('Stop'));
- expect(queryByText(/(\d{2}:){2}\d{2}/)).toBeNull();
+ expect(queryByText(/(\d{2}:){2}\d{2}\.\d{2}/)).toBeTruthy();
});
- test('pauses and resumes the stopwatch', () => {
+ test('pauses and resumes the stopwatch', async () => {
const { getByText } = render();
-
- fireEvent.press(getByText('Start'));
- fireEvent.press(getByText('Pause'));
- const pausedTime = getByText(/(\d{2}:){2}\d{2}/).textContent;
- fireEvent.press(getByText('Resume'));
- expect(getByText(/(\d{2}:){2}\d{2}/).textContent).not.toBe(pausedTime);
+ act(() => fireEvent.press(getByText('Start')));
+ act(() => jest.advanceTimersByTime(2000));
+ act(() => fireEvent.press(getByText('Stop')));
+
+ const pausedTime = getByText(/(\d{2}:){2}\d{2}\.\d{2}/).children.join();
+
+ act(() => fireEvent.press(getByText('Start')));
+ act(() => jest.advanceTimersByTime(50));
+
+ const resumedTime = getByText(/(\d{2}:){2}\d{2}\.\d{2}/).children.join();
+
+ expect(resumedTime).not.toBe(pausedTime);
});
- test('records and displays lap times', () => {
+ test('records and displays lap times', async () => {
const { getByText, getByTestId } = render();
+
+ const lapButton = getByTestId('lap-button')
fireEvent.press(getByText('Start'));
- fireEvent.press(getByText('Lap'));
- expect(getByTestId('lap-list')).toContainElement(getByText(/(\d{2}:){2}\d{2}/));
- fireEvent.press(getByText('Lap'));
- expect(getByTestId('lap-list').children.length).toBe(2);
+ act(() => jest.advanceTimersByTime(2000));
+
+ act(() => fireEvent.press(lapButton));
+
+ act(() => jest.advanceTimersByTime(1000));
+
+ act(() => fireEvent.press(lapButton));
+
+ // should have both laps now
+ const lapOneTime = getByTestId('time-0');
+ const lapTwoTime = getByTestId('time-1');
+
+ // verify there are two laps
+
+
+ // lap two should be 2 seconds and lap one should be 1 second
+ expect(lapTwoTime.children.join()).toBe('00:00:02.00');
+ expect(lapOneTime.children.join()).toBe('00:00:01.00');
});
test('resets the stopwatch', () => {
- const { getByText, queryByTestId } = render();
-
- fireEvent.press(getByText('Start'));
- fireEvent.press(getByText('Lap'));
- fireEvent.press(getByText('Reset'));
+ const { getByText, queryByTestId, getByTestId} = render();
+
+ const startButton = getByTestId('start-button');
+ const lapButton = getByTestId('lap-button');
+ const resetButton = getByTestId('reset-button');
+
+ act(() => fireEvent.press(startButton));
+ act(() => fireEvent.press(lapButton));
+ act(() => fireEvent.press(resetButton));
+
+ expect(getByText('00:00:00.00')).toBeTruthy();
+
+ const lapOneTime = queryByTestId('time-0');
+ expect(lapOneTime).toBeNull();
+ });
+
+ test('minimum and maximum lap are shown appropiately with unique colors', () => {
+ const { getByText, queryByTestId, getByTestId} = render();
+
+ const startButton = getByTestId('start-button');
+ const lapButton = getByTestId('lap-button');
+
+ act(() => fireEvent.press(startButton));
+ act(() => jest.advanceTimersByTime(1000));
+ act(() => fireEvent.press(lapButton));
+ act(() => jest.advanceTimersByTime(2000));
+ act(() => fireEvent.press(lapButton));
+ act(() => jest.advanceTimersByTime(5000));
+ act(() => fireEvent.press(lapButton));
+
+ // so min lap is 1 second and max lap is 5 seconds
+ const lapOneTime = getByTestId('time-0');
+ const lapTwoTime = getByTestId('time-1');
+ const lapThreeTime = getByTestId('time-2');
+
+ const lapOneColor = lapOneTime.props.style.find(s => s.color).color;
+ const lapThreeColor = lapThreeTime.props.style.find(s => s.color).color;
+ // lapOne is max with 5 seconds and lapThree is min with 1 second
+ // confirm that lapOneTime color is red and lapThreeTime color is green
+ expect(lapOneColor).toBe(lapColors.maxLap);
+ expect(lapThreeColor).toBe(lapColors.minLap);
+ });
+
+ test('disable adding laps when paused', () => {
+ const { getByText, queryByTestId, getByTestId} = render();
+
+ const startButton = getByTestId('start-button');
+ const lapButton = getByTestId('lap-button');
+ const stopButton = getByTestId('stop-button');
+
+ act(() => fireEvent.press(startButton));
+ act(() => jest.advanceTimersByTime(1000));
+ act(() => fireEvent.press(stopButton));
+
+ act(() => fireEvent.press(lapButton));
+
+ // shouldn't have added the lap because the stopwatch is paused
- expect(getByText('00:00:00')).toBeTruthy();
- expect(queryByTestId('lap-list')).toBeNull();
+ const lapOneTime = queryByTestId('time-0');
+ expect(lapOneTime).toBeNull();
});
});