Skip to content
Open
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>Template Site</title>
<title>Wilson Chu's Frontend Challenge Submission 2024</title>
</head>
<body>
<section id='root'></section>
Expand Down
52 changes: 31 additions & 21 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
:root {
--shopify-green: #96bf48;
--shopify-dark-green: #769241;
}

html, body, #root, .stopwatch, .stopwatch-content, .stopwatch-buttons {
height: -webkit-fill-available;
height: 100%;
}

.clickable-button:hover {
cursor: pointer;
}

body {
background-color: #f1f1f1;
margin: 0px;
margin: 0;
}

.stopwatch-title {
background-color: #303030;
margin: 0px;
background-color: var(--shopify-green);
margin: 0;
color: white;
padding-left: 16px;
padding: 10px 0px 10px 16px;
padding: 1em 0 1em 0;
text-align: center;
font-style: italic;
}

.stopwatch-content {
Expand All @@ -23,49 +33,49 @@ body {
display: flex;
flex-direction: column;
background-color: #ebebeb;
padding: 16px 12px;
width: 200px;
padding: 1em 0.75em;
width: 12.5em;
}

.stopwatch-buttons button:focus {
outline: none;
border: 2px solid #000000;
border: 1px solid var(--shopify-dark-green);
}

.stopwatch-buttons button {
margin: 7px 0px;
margin: 0.75em 0;
background-color: #fafafa;
border: 0px solid #fafafa;
border: 0 solid #fafafa;
text-align: left;
border-radius: 0.5rem;
padding: 7px 0px 7px 15px;
box-shadow: 0.5px 0.5px gray;
padding: 0.75em 0 0.75em 1em;
box-shadow: 1px 1px gray;
}

.stopwatch-time {
margin-left: auto;
margin-right: auto;
margin-top: 20px;
padding: 50px;
background-color: #ffffff;
margin-top: 1.25em;
padding: 3.25em;
background-color: white;
height: fit-content;
border-radius: 0.75rem;
width: 50%;
text-align: -webkit-center;
text-align: center;
box-shadow: 0.5px 0.5px gray;
}

.stopwatch-time p {
font-size: xxx-large;
font-size: 4rem;
}

.stopwatch-laptimes ul {
list-style: none;
padding: 0px;
padding: 0;
}

.stopwatch-laptimes li {
padding: 10px 0px;
padding: 0.75em 0;
border-bottom: 1px solid #ebebeb;
font-size: x-large;
font-size: 1.75rem;
}
140 changes: 77 additions & 63 deletions src/StopWatch.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,91 @@
import React, { useState, useEffect, useCallback } from 'react'
import StopWatchButton from './StopWatchButton'
import React, { useState, useEffect, useCallback } from 'react';
import StopWatchButton from './StopWatchButton';

// Function to format the time. This is necessary since both the time and lap times need to be formatted
export function formatTime(time: number): string {
// Format the time in mm:ss:ms. Display hours only if reached
const hours = Math.floor(time / 360000);
const minutes = Math.floor((time % 360000) / 6000);
const seconds = Math.floor((time % 6000) / 100);
const milliseconds = time % 100;
// Format the minutes, seconds, and milliseconds to be two digits
const formattedMinutes = minutes.toString().padStart(2, '0');
const formattedSeconds = seconds.toString().padStart(2, '0');
const formattedMilliseconds = milliseconds.toString().padStart(2, '0');
// If stopwatch reaches at least an hour, display the hours
if (hours > 0) {
const formattedHours = hours.toString().padStart(2, '0');
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}:${formattedMilliseconds}`;
}
// Combine the values into a string
const formattedTime = `${formattedMinutes}:${formattedSeconds}:${formattedMilliseconds}`;
return formattedTime;
// Format the time in mm:ss:ms. Display hours only if reached
const hours = Math.floor(time / 360000);
const minutes = Math.floor((time % 360000) / 6000);
const seconds = Math.floor((time % 6000) / 100);
const milliseconds = time % 100;
// Format the minutes, seconds, and milliseconds to be two digits
const formattedMinutes = minutes.toString().padStart(2, '0');
const formattedSeconds = seconds.toString().padStart(2, '0');
const formattedMilliseconds = milliseconds.toString().padStart(2, '0');
// If stopwatch reaches at least an hour, display the hours
if (hours > 0) {
const formattedHours = hours.toString().padStart(2, '0');
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}:${formattedMilliseconds}`;
}
// Combine the values into a string
const formattedTime = `${formattedMinutes}:${formattedSeconds}:${formattedMilliseconds}`;
return formattedTime;
}

export default function StopWatch() {
// State to track the time, whether the timer is on/off, and the lap times
const [time, setTime] = useState(0);
const [timerOn, setTimerOn] = useState(false);
const [lapTimes, setLapTimes] = useState<number[]>([]);
// State to track the time, whether the timer is on/off, and the lap times
const [time, setTime] = useState(0);
const [timerOn, setTimerOn] = useState(false);
const [lapTimes, setLapTimes] = useState<number[]>([]);

// Stops the timer, resets the time, and clears the lap times. useCallback is used to prevent unnecessary re-renders
const handleReset = useCallback(() => {
setTimerOn(false);
setTime(0);
setLapTimes([]);
}, []);

// Stops the timer, resets the time, and clears the lap times. useCallback is used to prevent unnecessary re-renders
const handleReset = useCallback(() => {
setTimerOn(false);
setTime(0);
setLapTimes([]);
}, []);
// Every time timerOn changes, we start or stop the timer
// useEffect is necessary since setInterval changes the state and we don't want to create an infinite loop
useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null;

// Every time timerOn changes, we start or stop the timer
// useEffect is necessary since setInterval changes the state and we don't want to create an infinite loop
useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null;
if (timerOn) {
interval = setInterval(() => setTime(time => time + 1), 10);
}

if (timerOn) {
interval = setInterval(() => setTime(time => time + 1), 10)
}
return () => { clearInterval(interval); }; // Clears the interval when the component unmounts or timerOn changes
}, [timerOn]);

return () => {clearInterval(interval)} // Clears the interval when the component unmounts or timerOn changes
}, [timerOn])
return (
<div className='stopwatch'>
<h1 className='stopwatch-title'>stopwatchify</h1>
<div className='stopwatch-content'>
<div className='stopwatch-buttons'>
<StopWatchButton type={'start'} onClick={() => setTimerOn(true)} className={'clickable-button'}></StopWatchButton>
<StopWatchButton type={'stop'} onClick={() => setTimerOn(false)} className={'clickable-button'}></StopWatchButton>
<StopWatchButton type={'lap'} onClick={() => setLapTimes([...lapTimes, time])} timerOn={timerOn} lapTimes={lapTimes} className={'clickable-button'}></StopWatchButton>
<StopWatchButton type={'reset'} onClick={handleReset} time={time} className={'clickable-button'}></StopWatchButton>
</div>
<div className='stopwatch-time'>
<p>{formatTime(time)}</p>
{/* Display the numbered lap times */}
{lapTimes.length > 0 && (
<div className='stopwatch-laptimes'>
<p>Lap Times</p>
<ul>

return(
<div className='stopwatch'>
<h1 className='stopwatch-title'>StopWatch</h1>
<div className='stopwatch-content'>
<div className='stopwatch-buttons'>
<StopWatchButton type={'start'} onClick={() => setTimerOn(true)}></StopWatchButton>
<StopWatchButton type={'stop'} onClick={() => setTimerOn(false)}></StopWatchButton>
<StopWatchButton type={'lap'} onClick={() => setLapTimes([...lapTimes, time])} timerOn={timerOn} lapTimes={lapTimes}></StopWatchButton>
<StopWatchButton type={'reset'} onClick={handleReset} time={time}></StopWatchButton>
</div>
<div className='stopwatch-time'>
<p>{formatTime(time)}</p>
{/* Display the numbered lap times */}
{lapTimes.length > 0 && (
<div className='stopwatch-laptimes'>
<p>Lap times</p>
<ul>
{lapTimes.map((lapTime, index) => {
return <li key={index}>{(index + 1)+'.'} {formatTime(lapTime)}</li>
})}
</ul>
</div>
)}
</div>
<li>
<strong>Total Time:</strong> {formatTime(time)}
</li>
{lapTimes.slice().reverse().map((lapTime, index) => {
const lapDifference = index === 0 ? 0 : lapTimes[index - 1] - lapTime;
return (
<li key={index}>
<strong>Lap {lapTimes.length - index}:</strong> {formatTime(lapTime)}
{index > 0 && (
<span className='lap-difference'>
({lapDifference >= 0 ? '+' : '-'}{formatTime(Math.abs(lapDifference))})
</span>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
)
</div>
</div>
);
}
94 changes: 48 additions & 46 deletions src/StopWatchButton.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,56 @@
import React from 'react'
import React from 'react';

// Maximum number of laps that can be recorded
const maxLaps = 25;

// Define the props for the StopWatchButton component
type StopWatchButtonProps = {
type: 'start' | 'stop' | 'lap' | 'reset';
onClick?: () => void;
timerOn?: boolean;
time?: number;
lapTimes?: number[];
type: 'start' | 'stop' | 'lap' | 'reset';
onClick?: () => void;
timerOn?: boolean;
time?: number;
lapTimes?: number[];
className?: string;
};

export default function StopWatchButton({ type, onClick, timerOn, time, lapTimes }: StopWatchButtonProps) {
// Determine the button text based on the type and add corresponding tabIndex
let buttonText, tabIndex;
switch(type) {
case 'start':
buttonText = 'Start';
tabIndex = 1;
break;
case 'stop':
buttonText = 'Stop';
tabIndex = 2;
break;
case 'lap':
buttonText = 'Record Lap';
tabIndex = 3;
break;
case 'reset':
buttonText = 'Reset';
tabIndex = 4;
break;
default:
buttonText = '';
tabIndex = 0;
}
// Determine whether the reset or lap buttons should be disabled
const isLapDisabled = !timerOn || (lapTimes && lapTimes.length === 25);
const isResetDisabled = time === 0;
return(
<button
onClick={onClick}
aria-label={type}
tabIndex={tabIndex}
// Disable the lap button when the timer is stopped or when the max number of lap times is reached. Disable reset button when the timer is already reset
disabled={(type === 'lap' && isLapDisabled) || (type === 'reset' && isResetDisabled)}
>
{/* Display the button text, otherwise display the max laps reached message when max number is reached */}
{lapTimes && lapTimes.length === maxLaps && timerOn && type === 'lap' ? "Maximum laps reached" : buttonText}
</button>
)

export default function StopWatchButton({ type, onClick, timerOn, time, lapTimes, className }: StopWatchButtonProps) {
// Determine the button text based on the type and add corresponding tabIndex
let buttonText, tabIndex;
switch (type) {
case 'start':
buttonText = 'Start';
tabIndex = 1;
break;
case 'stop':
buttonText = 'Stop';
tabIndex = 2;
break;
case 'lap':
buttonText = 'Record Lap';
tabIndex = 3;
break;
case 'reset':
buttonText = 'Reset';
tabIndex = 4;
break;
default:
buttonText = '';
tabIndex = 0;
}
// Determine whether the reset or lap buttons should be disabled
const isLapDisabled = !timerOn || (lapTimes && lapTimes.length === 25);
const isResetDisabled = time === 0;
return (
<button
onClick={onClick}
aria-label={type}
tabIndex={tabIndex}
className={className}
// Disable the lap button when the timer is stopped or when the max number of lap times is reached. Disable reset button when the timer is already reset
disabled={(type === 'lap' && isLapDisabled) || (type === 'reset' && isResetDisabled)}
>
{/* Display the button text, otherwise display the max laps reached message when max number is reached */}
{lapTimes && lapTimes.length === maxLaps && timerOn && type === 'lap' ? "Maximum laps reached" : buttonText}
</button>
);
}