diff --git a/package.json b/package.json index 08d51ff..6d47478 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-regular-svg-icons": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -19,6 +23,7 @@ "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "classnames": "^2.5.1", "dayjs": "^1.11.13", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.0.0", diff --git a/src/components/button.tsx b/src/components/button.tsx index cf26ec8..19550dc 100644 --- a/src/components/button.tsx +++ b/src/components/button.tsx @@ -1,14 +1,27 @@ +import { IconDefinition } from "@fortawesome/fontawesome-svg-core" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { PropsWithChildren } from "react" +import classnames from "classnames" interface IButtonProps extends PropsWithChildren { onClick?: () => void + text?: string + icon?: IconDefinition + className?: string + disabled?: boolean } export const Button = (props: IButtonProps) => { - const { onClick, children } = props + const { children, className, disabled, icon, onClick, text } = props return ( - ) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6f3bd36..30499cc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -16,3 +16,20 @@ export const diffBetweenTimestamps = ( return date1.diff(date2) } +/** + * + * @param elapsedTime in milliseconds + * @returns Formatted string in `HH:MM:ss` / `MM:ss` format + */ +export const formatDuration = (elapsedTime: number) => { + const duration = dayjs.duration(elapsedTime) + + if (duration.hours()) { + //TODO Check requirements for hour display + // This depends on the requirements. The `MM:ss` format has been defined, + // but not what should happen with the edge case of reaching an hour or more + return duration.format("HH:MM:ss") + } + + return duration.format("MM:ss") +} diff --git a/src/routes/timer/components/controls.tsx b/src/routes/timer/components/controls.tsx new file mode 100644 index 0000000..b0251cc --- /dev/null +++ b/src/routes/timer/components/controls.tsx @@ -0,0 +1,50 @@ +import { + faArrowRotateLeft, + faFlagCheckered, + faPause, + faPlay, +} from "@fortawesome/free-solid-svg-icons" + +import classNames from "classnames" + +import { Button } from "../../../components/button" + +interface ITimerControlsProps { + addLap: () => void + elapsedTime: number + resetTimer: () => void + timerActive: boolean + toggleTimer: () => void +} + +export const TimerControls = (props: ITimerControlsProps) => { + const { addLap, elapsedTime, resetTimer, timerActive, toggleTimer } = props + + return ( +
+
+ ) +} diff --git a/src/routes/timer/components/lap.tsx b/src/routes/timer/components/lap.tsx index efe447a..0c0762b 100644 --- a/src/routes/timer/components/lap.tsx +++ b/src/routes/timer/components/lap.tsx @@ -1 +1,18 @@ -export const Lap = () => I'm going to display a lap time in the future +interface ITimerDisplayProps { + formattedTime: string + index: number + onClick: (index: number) => void +} + +export const Lap = (props: ITimerDisplayProps) => { + const { formattedTime, onClick, index } = props + + //TODO Add a proper button/icon for removal functionality + + return ( +
onClick(index)} className="text-2xl m-2 cursor-pointer"> + #{index + 1} + {formattedTime} +
+ ) +} diff --git a/src/routes/timer/components/laps.tsx b/src/routes/timer/components/laps.tsx new file mode 100644 index 0000000..794c39b --- /dev/null +++ b/src/routes/timer/components/laps.tsx @@ -0,0 +1,19 @@ +import { Lap } from "./lap" + +interface ILapsProps { + laps: string[] + removeLap: (index: number) => void +} + +export const Laps = (props: ILapsProps) => { + const { laps, removeLap } = props + + return laps.map((lap, i) => ( + + )) +} diff --git a/src/routes/timer/components/timerDisplay.tsx b/src/routes/timer/components/timerDisplay.tsx index d47e135..80022ce 100644 --- a/src/routes/timer/components/timerDisplay.tsx +++ b/src/routes/timer/components/timerDisplay.tsx @@ -1,5 +1,5 @@ -import dayjs from "dayjs" import { useMemo } from "react" +import { formatDuration } from "../../../lib/utils" interface ITimerDisplayProps { elapsedTime: number @@ -9,16 +9,7 @@ export const TimerDisplay = (props: ITimerDisplayProps) => { const { elapsedTime } = props const formattedTime = useMemo(() => { - const duration = dayjs.duration(elapsedTime) - - if (duration.hours()) { - //TODO Check requirements for hour display - // This depends on the requirements. The `MM:ss` format has been defined, - // but not what should happen with the edge case of reaching an hour or more - return duration.format("HH:MM:ss") - } - - return duration.format("MM:ss") + return formatDuration(elapsedTime) }, [elapsedTime]) return
{formattedTime}
diff --git a/src/routes/timer/timer.tsx b/src/routes/timer/timer.tsx index 8586e0d..68728f3 100644 --- a/src/routes/timer/timer.tsx +++ b/src/routes/timer/timer.tsx @@ -1,15 +1,17 @@ import { useCallback, useEffect, useRef, useState } from "react" import dayjs from "dayjs" -import { Button } from "../../components/button" -import { diffBetweenTimestamps } from "../../lib/utils" +import { diffBetweenTimestamps, formatDuration } from "../../lib/utils" import { TimerDisplay } from "./components/timerDisplay" +import { TimerControls } from "./components/controls" +import { Laps } from "./components/laps" export const Timer = () => { const [elapsedTime, setElapsedTime] = useState(0) // Elapsed time in milliseconds const [timestamp, setTimestamp] = useState("") // Timestamp of timer start/unpause const [timerActive, setTimerActive] = useState(false) + const [laps, setLaps] = useState>([]) const interval = useRef(undefined) @@ -33,6 +35,7 @@ export const Timer = () => { return () => clearInterval(interval.current) }, [addElapsedTime, elapsedTime, timerActive, timestamp]) + // No reason to memoize these functions const startTimer = () => { setTimestamp(dayjs().toISOString()) setTimerActive(true) @@ -48,19 +51,38 @@ export const Timer = () => { const resetTimer = () => { setTimerActive(false) setElapsedTime(0) + setLaps([]) } + const addLap = () => { + // This could be slightly inaccurate as is (within 100 ms, the interval timer) depending on when the button is clicked. + // To fix that this step would need to update elapsed Time and set a new timestamp - I won't go into those details for demo purposes here + // Creates a new array so removeLap stays up to date + setLaps([...laps, formatDuration(elapsedTime)]) + } + + const removeLap = useCallback( + (index: number) => { + // Don't mutate the original array + const lapsCopy = [...laps] + lapsCopy.splice(index, 1) + setLaps(lapsCopy) + }, + [laps] + ) + return ( <>

Trial Timer

-

{timestamp}

-

{elapsedTime}

-
- - - -
+ + ) } diff --git a/yarn.lock b/yarn.lock index 0f68db1..65d4462 100644 --- a/yarn.lock +++ b/yarn.lock @@ -359,6 +359,39 @@ "@eslint/core" "^0.10.0" levn "^0.4.1" +"@fortawesome/fontawesome-common-types@6.7.2": + version "6.7.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz#7123d74b0c1e726794aed1184795dbce12186470" + integrity sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg== + +"@fortawesome/fontawesome-svg-core@^6.7.2": + version "6.7.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz#0ac6013724d5cc327c1eb81335b91300a4fce2f2" + integrity sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA== + dependencies: + "@fortawesome/fontawesome-common-types" "6.7.2" + +"@fortawesome/free-regular-svg-icons@^6.7.2": + version "6.7.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz#f1651e55e6651a15589b0569516208f9c65f96db" + integrity sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g== + dependencies: + "@fortawesome/fontawesome-common-types" "6.7.2" + +"@fortawesome/free-solid-svg-icons@^6.7.2": + version "6.7.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz#fe25883b5eb8464a82918599950d283c465b57f6" + integrity sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA== + dependencies: + "@fortawesome/fontawesome-common-types" "6.7.2" + +"@fortawesome/react-fontawesome@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz#68b058f9132b46c8599875f6a636dad231af78d4" + integrity sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g== + dependencies: + prop-types "^15.8.1" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -867,6 +900,11 @@ chokidar@^3.6.0: optionalDependencies: fsevents "~2.3.2" +classnames@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -1413,7 +1451,7 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -loose-envify@^1.1.0: +loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -1503,7 +1541,7 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== -object-assign@^4.0.1: +object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -1652,6 +1690,15 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -1670,6 +1717,11 @@ react-dom@^18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" +react-is@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + react-refresh@^0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"