Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11,190 changes: 4,051 additions & 7,139 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@babel/preset-env": "^7.23.8",
"@babel/preset-react": "^7.23.3",
"@babel/preset-typescript": "^7.23.3",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.11",
"@types/node": "^16.18.30",
Expand All @@ -31,6 +32,7 @@
"file-loader": "^6.2.0",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"react-test-renderer": "^18.2.0",
Expand All @@ -45,9 +47,12 @@
"webpack-obj-loader": "^1.0.4"
},
"jest": {
"testEnvironment": "jsdom",
"transform": {
"^.+\\.(js|jsx|ts|tsx)$": "babel-jest"
},
"testEnvironment": "jsdom"
"moduleNameMapper": {
"^.+\\.(css)$": "identity-obj-proxy"
}
}
}
5 changes: 4 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Template Site</title>
<title>Stopwatch App</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;800&display=swap" rel="stylesheet">
</head>
<body>
<section id='root'></section>
Expand Down
23 changes: 17 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import React from 'react'
import './App.css'
import StopWatch from './StopWatch'
import React, {useEffect, useState} from 'react'
import StopWatch from "./StopWatch";
import ThemeSelector from "./ThemeSelector";
import "./styles/app.css"

export default function App() {
return(
<StopWatch />
)

const [theme, setTheme] = useState<string>('');

useEffect(() => {
document.documentElement.style.setProperty('--main-accent-color', theme);
}, [theme]);

return(
<>
<StopWatch />
<ThemeSelector theme={theme} themeSelect={setTheme} />
</>
)
}
170 changes: 101 additions & 69 deletions src/StopWatch.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,109 @@
import React, { useState, useEffect, useCallback } from 'react'
import StopWatchButton from './StopWatchButton'
import React, { useState, useEffect, useRef } from "react";
import StopWatchArc from "./StopWatchArc";
import StopWatchButton from "./StopWatchButton";
import "./styles/stopwatch.css";

// 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}`;
const Stopwatch = () => {
const [isRunning, setIsRunning] = useState<boolean>(false);
const [elapsedTime, setElapsedTime] = useState<number>(0);
const [laps, setLaps] = useState<Array<number>>([]);
const intervalRef = useRef<null | NodeJS.Timer>(null);

useEffect(() => {
if (isRunning) {
const startTime = Date.now() - elapsedTime;
intervalRef.current = setInterval(() => {
setElapsedTime(Date.now() - startTime);
}, 1);
} else {
clearInterval(intervalRef.current);
}
// 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[]>([]);
return () => clearInterval(intervalRef.current);
}, [isRunning]);

const startStopWatch = () => {
setIsRunning(true);
};

const pauseStopwatch = () => {
clearInterval(intervalRef.current);
setIsRunning(false);
};

const resetStopWatch = () => {
setElapsedTime(0);
setIsRunning(false);
setLaps([]);
};

const stopWatchLap = () => {
setLaps([...laps, elapsedTime]);
};

const formatTime = (time: number) => {
let seconds: any = Math.floor(time / 1000);
let minutes: any = Math.floor(seconds / 60);
seconds = `0${seconds % 60}`.slice(-2);
minutes = `0${minutes % 60}`.slice(-2);

// 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([]);
}, []);
return `${minutes}:${seconds}`;
};

// 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;
const formatMS = (time: number) => {
const milliseconds = `00${time % 100}`.slice(-2);

if (timerOn) {
interval = setInterval(() => setTime(time => time + 1), 10)
}
return `${milliseconds}`;
};

return () => {clearInterval(interval)} // Clears the interval when the component unmounts or timerOn changes
}, [timerOn])
return (
<div className="stopwatch">
<h1 className="stopwatch__heading">Shopify Internship S'24 — Challenge<span className="float-right">✶✶✶</span></h1>
<div className={`stopwatch__timer
${elapsedTime === 0 ? "stopwatch__timer--initialize " : ""}
${isRunning ? "stopwatch__timer--active " : ""}
${laps.length > 0 ? "stopwatch__timer--laps" : ""}`}>
<StopWatchArc />
<span className="stopwatch__time">
{formatTime(elapsedTime)}
<span className="stopwatch__milliseconds">{formatMS(elapsedTime)}</span>
</span>
{laps.length > 0 && (
<div className="stopwatch__laps">
<ul className="stopwatch__list">
{laps.map((lap, index) => (
<li key={index}>
Lap {index + 1}: {formatTime(lap)}.{formatMS(lap)}
</li>
))}
</ul>
</div>
)}
</div>
<div className="stopwatch__actions">
<StopWatchButton onClick={startStopWatch}
label="Play"
hidden={isRunning}>
<span className="shape__play">Play</span>
</StopWatchButton>
<StopWatchButton onClick={pauseStopwatch}
label="Pause"
hidden={!isRunning}>
<span className="shape__pause">Pause</span>
</StopWatchButton>
<StopWatchButton onClick={resetStopWatch}
label="Reset"
hidden={isRunning}>
Reset
</StopWatchButton>
<StopWatchButton onClick={stopWatchLap}
label="Lap"
hidden={!isRunning}>
Lap
</StopWatchButton>
</div>
</div>
);
};

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>
</div>
</div>
)
}
export default Stopwatch;
18 changes: 18 additions & 0 deletions src/StopWatchArc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from "react";
import "./styles/arc.css"
const StopWatchArc = () => {
return (
<svg className="stopwatch__arc" height="400" version="1.1" width="400" xmlns="http://www.w3.org/2000/svg">
<defs>
<mask id="mask">
<circle cx="50%" cy="50%" fill="white" r="150"></circle>
<circle cx="50%" cy="50%" fill="none" r="130" className="arc arc__mask"></circle>
</mask>
</defs>
<circle className="arc" cx="50%" cy="50%" fill="none" width="400" height="400" r="130"></circle>
<circle className="arc arc__primary" cx="50%" cy="50%" fill="none" width="400" height="400" mask="url(#mask)" r="130"></circle>
</svg>
)
}

export default StopWatchArc
76 changes: 25 additions & 51 deletions src/StopWatchButton.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,28 @@
import React from 'react'
import React from "react";

// Maximum number of laps that can be recorded
const maxLaps = 25;
interface StopwatchButtonProps {
onClick: () => void;
children: React.ReactNode;
label: string;
hidden?: boolean;
className?: string;
}

// Define the props for the StopWatchButton component
type StopWatchButtonProps = {
type: 'start' | 'stop' | 'lap' | 'reset';
onClick?: () => void;
timerOn?: boolean;
time?: number;
lapTimes?: number[];
const StopwatchButton: React.FC<StopwatchButtonProps> = ({
onClick,
children,
label,
hidden,
className,
}) => {
const baseClass = "stopwatch__button";
const classNames = className ? `${baseClass} ${className}` : baseClass;

return hidden ? null : (
<button className={classNames} onClick={onClick} aria-label={label}>
{children}
</button>
);
};

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 StopwatchButton;
31 changes: 31 additions & 0 deletions src/ThemeSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";
import "./styles/theme-selector.css";

interface ThemeSelectorProps {
themeSelect: (accentColor: string) => void;
theme: string;
}

const ThemeSelector: React.FC<ThemeSelectorProps> = ({ theme, themeSelect }) => {
const themes = [
'#FF5E5E',
'#A388EE',
'#88cf97',
];

return (
<div className="theme-selector">
<span className="theme-selector__heading">Select theme</span>
{themes.map((color: string) => (
<button
key={color}
className={`theme-selector__button ${theme === color ? "theme-selector__button--active" : ""}`}
onClick={() => themeSelect(color)}
style={{ '--theme-color': color } as React.CSSProperties}
></button>
))}
</div>
);
}

export default ThemeSelector;
Loading