diff --git a/package.json b/package.json index 716ea1e..514b43d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "abcjs": "^6.3.0", "classnames": "^2.5.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zustand": "^4.5.2" }, "devDependencies": { "@types/react": "^18.2.66", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb48ff5..a226821 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + zustand: + specifier: ^4.5.2 + version: 4.5.2(@types/react@18.2.66)(react@18.2.0) devDependencies: '@types/react': @@ -828,7 +831,6 @@ packages: /@types/prop-types@15.7.12: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} - dev: true /@types/react-dom@18.2.22: resolution: {integrity: sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==} @@ -842,11 +844,9 @@ packages: '@types/prop-types': 15.7.12 '@types/scheduler': 0.23.0 csstype: 3.1.3 - dev: true /@types/scheduler@0.23.0: resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} - dev: true /@types/semver@7.5.8: resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -1246,7 +1246,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: true /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -2397,6 +2396,14 @@ packages: punycode: 2.3.1 dev: true + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -2484,3 +2491,23 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zustand@4.5.2(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.66 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false diff --git a/src/App.tsx b/src/App.tsx index 164c11f..c498747 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,13 +3,16 @@ import Button from "./Components/Button"; import Modal from "./Components/Modal"; import NoteDisplay from "./Components/NoteDisplay"; import { IconSaxophone } from "./Components/Icons/IconSaxophone"; +import useSessionTracker from "./Hooks/useSessionTracker"; function App() { const [isStarted, setIsStarted] = useState(false); + const { startSession, isEnded, metrics } = useSessionTracker(); + return ( -
-
- {!isStarted ? ( +
+ {!isStarted ? ( +
@@ -31,18 +34,55 @@ function App() { } successButton={ } /> - ) : ( - - )} -
+
+ ) : !isEnded ? ( + + ) : ( +
+
+ Session has ended! +
+ +
+ + + +
+ + +
+ )}
); } diff --git a/src/Components/Button/index.tsx b/src/Components/Button/index.tsx index 7f304aa..fdb3816 100644 --- a/src/Components/Button/index.tsx +++ b/src/Components/Button/index.tsx @@ -2,13 +2,17 @@ import { FC } from "react"; const Button: FC<{ onClick?: () => void; + bgFill?: boolean; + className?: string; children: React.ReactNode; -}> = ({ onClick, children }) => { +}> = ({ onClick, children, bgFill, className }) => { return ( diff --git a/src/Components/Icons/IconDoorOpen.tsx b/src/Components/Icons/IconDoorOpen.tsx new file mode 100644 index 0000000..2f2e3f2 --- /dev/null +++ b/src/Components/Icons/IconDoorOpen.tsx @@ -0,0 +1,26 @@ +import { SVGProps } from "react"; + +function IconDoorOpen(props: SVGProps) { + return ( + + + + + + + + ); +} + +export default IconDoorOpen; diff --git a/src/Components/ListboxSelector/index.tsx b/src/Components/ListboxSelector/index.tsx index 8e83204..f1c4de4 100644 --- a/src/Components/ListboxSelector/index.tsx +++ b/src/Components/ListboxSelector/index.tsx @@ -14,7 +14,7 @@ function ListboxSelector({ return (
- + {buttonTitle} - + {options.map((option, index) => ( `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ - active ? "bg-slate-700 text-white" : "text-white" + active ? "bg-mutedSecondary text-white" : "text-white" }` } value={option.value} @@ -50,7 +50,7 @@ function ListboxSelector({ {option.label} {selected ? ( - + ) : null} diff --git a/src/Components/Modal/index.tsx b/src/Components/Modal/index.tsx index 5c22d58..3b9e26d 100644 --- a/src/Components/Modal/index.tsx +++ b/src/Components/Modal/index.tsx @@ -44,7 +44,7 @@ function Modal({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + { const { playedNote, initAudio, stopAudio } = useAudioProcessor(notes); - const [difficulty, setDifficulty] = useState(EDifficulty.easy); const [isNoteCorrect, setIsNoteCorrect] = useState(false); + const { addMissedNote, addSuccessfulNote } = useSessionTracker(); + const { metrics, changeDifficulty, formatCountdown, endSession } = + useSessionTracker(); const [randomNote, setRandomNote] = useState({ frequency: 0, abcNote: "", note: "", - difficulty, + difficulty: metrics.difficulty, }); useEffect(() => { @@ -28,28 +31,30 @@ const NoteDisplay = ({ isStarted, setIsStarted }: Props) => { useEffect(() => { const timer = setTimeout(() => { - if (!playedNote || !randomNote) return; + setIsNoteCorrect(playedNote.note === randomNote.note); + if (playedNote.note === randomNote.note) { - setIsNoteCorrect(true); + addSuccessfulNote(); } else { - setIsNoteCorrect(false); + addMissedNote(); } }, 1500); return () => clearTimeout(timer); - }, [playedNote, randomNote]); + }, [playedNote.note, randomNote.note]); const notationRef = useRef(null); useEffect(() => { const changeNote = () => { - const random = NoteUtils.getRandomNote(difficulty); + const random = NoteUtils.getRandomNote(randomNote, metrics.difficulty); abcjs.renderAbc( notationRef.current!, NoteUtils.generateAbcNotation(random), { responsive: "resize", staffwidth: 120, + selectionColor: "white", } ); @@ -62,30 +67,55 @@ const NoteDisplay = ({ isStarted, setIsStarted }: Props) => { } changeNote(); - const intervalId = setInterval(changeNote, 4000); + const intervalId = setInterval( + changeNote, + metrics.difficulty === EDifficulty.easy ? 5000 : 4000 + ); return () => { clearInterval(intervalId); }; - }, [isStarted, difficulty]); + }, [isStarted, metrics.difficulty]); const handleStop = () => { stopAudio(); + endSession(); setIsStarted(false); }; return (
-
- +
+ + +

+ Remaining time: {formatCountdown()} +

+
+
+ +
+ changeDifficulty(value)} + buttonTitle="Change Difficulty" + value={metrics.difficulty} + options={difficultyOptions} + />
-
-
+ +
+

Performed

- + -

+

{playedNote.note ? playedNote.note : "No note played"}

@@ -93,24 +123,14 @@ const NoteDisplay = ({ isStarted, setIsStarted }: Props) => {

Difficulty

-

- {difficulty} +

+ {metrics.difficulty}

- -
- setDifficulty(value)} - buttonTitle="Change Difficulty" - value={difficulty} - options={difficultyOptions} - /> -
-
+
void; + endSession: () => void; + metrics: SessionMetrics; + addSuccessfulNote: () => void; + addMissedNote: () => void; + countdownTime: number; + changeDifficulty: (newDifficulty: EDifficulty) => void; + formatCountdown: () => string; +} => { + const { + countdownTime, + endSession, + isEnded, + isStarted, + metrics, + startSession, + addMissedNote, + setCountdownTime, + changeDifficulty, + addSuccessfulNote, + } = useSessionStore(); + + const formatCountdown = (): string => { + const minutes = Math.floor(countdownTime / 60); + const seconds = countdownTime % 60; + return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`; + }; + + useEffect(() => { + if (isStarted && !isEnded) { + const countdownTimer = setInterval(() => { + setCountdownTime(countdownTime - 1); + if (countdownTime <= 0) { + endSession(); + } + }, 1000); + return () => { + if (countdownTimer) { + clearInterval(countdownTimer); + } + }; + } + }, [isStarted, isEnded, countdownTime, endSession, setCountdownTime]); + + return { + isStarted, + isEnded, + startSession, + endSession, + metrics, + addSuccessfulNote, + addMissedNote, + changeDifficulty, + countdownTime, + formatCountdown, + }; +}; + +export default useSessionTracker; diff --git a/src/Stores/SessionStore/index.tsx b/src/Stores/SessionStore/index.tsx new file mode 100644 index 0000000..f60cb74 --- /dev/null +++ b/src/Stores/SessionStore/index.tsx @@ -0,0 +1,65 @@ +import { create } from "zustand"; +import { SessionStore } from "./types"; +import { EDifficulty } from "../../types"; + +const useSessionStore = create()((set) => ({ + isEnded: false, + isStarted: false, + metrics: { + difficulty: EDifficulty.easy, + successfulNotes: 0, + missedNotes: 0, + }, + countdownTime: 120, + startSession: (): void => { + set((state) => ({ + ...state, + isStarted: true, + isEnded: false, + countdownTime: 120, + metrics: { + difficulty: EDifficulty.easy, + successfulNotes: 0, + missedNotes: 0, + }, + })); + }, + endSession: (): void => { + set((state) => ({ + ...state, + isStarted: false, + isEnded: true, + })); + }, + changeDifficulty: (newDifficulty: EDifficulty): void => { + set((state) => ({ + ...state, + metrics: { + ...state.metrics, + difficulty: newDifficulty, + }, + })); + }, + addSuccessfulNote: (): void => { + set((state) => ({ + ...state, + metrics: { + ...state.metrics, + successfulNotes: state.metrics.successfulNotes + 1, + }, + })); + }, + addMissedNote: (): void => { + set((state) => ({ + ...state, + metrics: { + ...state.metrics, + missedNotes: state.metrics.missedNotes + 1, + }, + })); + }, + setCountdownTime: (newTime: number) => + set((state) => ({ ...state, countdownTime: newTime })), +})); + +export default useSessionStore; diff --git a/src/Stores/SessionStore/types.ts b/src/Stores/SessionStore/types.ts new file mode 100644 index 0000000..2b9e917 --- /dev/null +++ b/src/Stores/SessionStore/types.ts @@ -0,0 +1,20 @@ +import { EDifficulty } from "../../types"; + +export interface SessionStore { + isEnded: boolean; + isStarted: boolean; + metrics: SessionMetrics; + countdownTime: number; + setCountdownTime: (newTime: number) => void; + startSession: () => void; + endSession: () => void; + addSuccessfulNote: () => void; + changeDifficulty: (newDifficulty: EDifficulty) => void; + addMissedNote: () => void; +} + +export interface SessionMetrics { + difficulty: EDifficulty; + successfulNotes: number; + missedNotes: number; +} diff --git a/src/Utils/AudioUtils/index.ts b/src/Utils/AudioUtils/index.ts index f0e7137..0334928 100644 --- a/src/Utils/AudioUtils/index.ts +++ b/src/Utils/AudioUtils/index.ts @@ -1,3 +1,4 @@ +import useSessionStore from "../../Stores/SessionStore"; import { EDifficulty, INote } from "../../types"; export class AudioUtils { diff --git a/src/Utils/NoteUtils/index.ts b/src/Utils/NoteUtils/index.ts index 0879feb..eda86ea 100644 --- a/src/Utils/NoteUtils/index.ts +++ b/src/Utils/NoteUtils/index.ts @@ -204,14 +204,18 @@ export const notes: INote[] = [ class NoteUtils { /** * Get a random note from the notes array filtered by difficulty. + * @param lastNote The last note played. * @param difficulty The difficulty level to filter by. * @returns INote */ - public static getRandomNote(difficulty: string): INote { + public static getRandomNote(lastNote: INote, difficulty: EDifficulty): INote { const filteredNotes = notes.filter( (note) => note.difficulty === difficulty ); const randomIndex = Math.floor(Math.random() * filteredNotes.length); + if (lastNote && filteredNotes[randomIndex].note === lastNote.note) + return this.getRandomNote(lastNote, difficulty); + return filteredNotes[randomIndex]; } diff --git a/tailwind.config.js b/tailwind.config.js index 4ccdaa5..64ea283 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,7 +3,14 @@ export default { darkMode: "class", content: ["./src/**/*.{html,tsx,ts,js,jsx,css,scss,sass}"], theme: { - extend: {}, + extend: { + colors: { + background: "#121212", + primary: "#FACC15", + muted: "#212121", + mutedSecondary: "#313131", + }, + }, }, plugins: [], };