From abd066c59354b5a61376a7771432eb7ebcdbfb69 Mon Sep 17 00:00:00 2001 From: Sam Bender <2336186+rednebmas@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:27:30 -0800 Subject: [PATCH 1/2] suggested app refactoring --- apps/react/src/components/CardCarousel.tsx | 161 ++++++++++++++++++ .../src/components/feedback/Confetti.tsx | 50 ++++++ .../feedback/NetworkStateWrapper.tsx | 31 ++++ apps/react/src/components/feedback/index.ts | 1 + apps/react/src/index.css | 55 ++++++ apps/react/src/screens/AllDeckCardsScreen.tsx | 38 ++--- .../DeckStatsScreen/DeckStatsScreen.tsx | 143 ++++++++-------- .../src/screens/StudyScreen/StudyScreen.tsx | 114 ++----------- .../StudyScreen/StudyScreenEmptyState.tsx | 26 ++- 9 files changed, 413 insertions(+), 206 deletions(-) create mode 100644 apps/react/src/components/CardCarousel.tsx create mode 100644 apps/react/src/components/feedback/Confetti.tsx create mode 100644 apps/react/src/components/feedback/NetworkStateWrapper.tsx diff --git a/apps/react/src/components/CardCarousel.tsx b/apps/react/src/components/CardCarousel.tsx new file mode 100644 index 0000000..9e9e4cb --- /dev/null +++ b/apps/react/src/components/CardCarousel.tsx @@ -0,0 +1,161 @@ +import React, { useRef, useState, useLayoutEffect, useEffect } from 'react'; +import { FlashCard } from './FlashCard'; +import { CardWithAttempts } from 'MemoryFlashCore/src/redux/selectors/currDeckCardsWithAttempts'; +import { User } from 'MemoryFlashCore/src/types/User'; +import { isCardOwner } from '../utils/useIsCardOwner'; +import useWindowResize from '../screens/StudyScreen/useWindowResize'; +import { useAppSelector } from 'MemoryFlashCore/src/redux/store'; +import { Confetti } from './feedback/Confetti'; + +const getBestTime = (card: CardWithAttempts) => { + const correctAttempts = card.attempts.filter((a) => a.correct); + if (correctAttempts.length === 0) return null; + return Math.min(...correctAttempts.map((a) => a.timeTaken)); +}; + +interface CardCarouselProps { + cards: CardWithAttempts[]; + index: number; + hideFutureCards: boolean; + user?: User | null; + activePresentationMode: string | null; +} + +export const CardCarousel: React.FC = ({ + cards, + index, + hideFutureCards, + user, + activePresentationMode, +}) => { + const cardRefs = useRef([]); + const cardContainerRef = useRef(null); + const [cardsTranslation, setCardsTranslation] = useState(''); + const incorrect = useAppSelector((state) => state.scheduler.incorrect); + const [animationState, setAnimationState] = useState<'idle' | 'correct' | 'incorrect'>('idle'); + const [showConfetti, setShowConfetti] = useState(false); + const prevIncorrectRef = useRef(incorrect); + const prevIndexRef = useRef(index); + const prevBestTimeRef = useRef(null); + + const updateTranslation = () => { + let totalWidth = 0; + const cardContainerWidth = cardContainerRef.current?.offsetWidth || 0; + + cardRefs.current.slice(0, index + 1).forEach((ref, forEachIndex) => { + if (!ref) return; + let width = ref?.offsetWidth; + const computedStyle = window.getComputedStyle(ref); + const marginLeft = parseFloat(computedStyle.marginLeft) || 0; + const marginRight = parseFloat(computedStyle.marginRight) || 0; + width += marginLeft + marginRight; + + if (forEachIndex === index) { + totalWidth += width / 2 || 0; + } else { + totalWidth += width || 0; + } + }); + + const translation = cardContainerWidth / 2 - totalWidth; + setCardsTranslation(`translateX(${translation}px)`); + }; + + useEffect(() => { + cardRefs.current = cardRefs.current.slice(0, cards.length); + }, [cards.length]); + + useWindowResize(() => { + updateTranslation(); + }); + + useLayoutEffect(() => { + setTimeout(() => { + updateTranslation(); + }, 1000 / 30); + }, [cards.length, index, activePresentationMode]); + + // Detect incorrect answer + useEffect(() => { + if (incorrect && !prevIncorrectRef.current) { + setAnimationState('incorrect'); + const timer = setTimeout(() => setAnimationState('idle'), 400); + return () => clearTimeout(timer); + } + prevIncorrectRef.current = incorrect; + }, [incorrect]); + + // Track best time for current card + useEffect(() => { + const currentCard = cards[index]; + if (currentCard) { + prevBestTimeRef.current = getBestTime(currentCard); + } + }, [cards, index]); + + // Detect correct answer and check for new record + useEffect(() => { + if (index > prevIndexRef.current && cards.length > 0) { + setAnimationState('correct'); + const timer = setTimeout(() => setAnimationState('idle'), 300); + + // Check if we beat the previous best time (card at prevIndex is now answered) + const answeredCard = cards[prevIndexRef.current]; + if (answeredCard && prevBestTimeRef.current !== null) { + const newBestTime = getBestTime(answeredCard); + if (newBestTime !== null && newBestTime < prevBestTimeRef.current) { + setShowConfetti(true); + setTimeout(() => setShowConfetti(false), 100); + } + } + + prevIndexRef.current = index; + return () => clearTimeout(timer); + } + prevIndexRef.current = index; + }, [index, cards]); + + const cardOpacity = (_index: number) => { + if (_index === index) return 1; + if (_index > index && hideFutureCards) return 0; + if (_index === index + 1) return 0.75; + return 0.4; + }; + + const getCardAnimation = (_index: number) => { + if (_index !== index) return ''; + if (animationState === 'correct') return 'animate-card-correct'; + if (animationState === 'incorrect') return 'animate-card-incorrect'; + return ''; + }; + + return ( +
+ +
+ {cards.map((card, i) => { + const isOwner = isCardOwner(card, user ?? undefined); + const isActive = i === index; + return ( + (cardRefs.current[i] = el!)} + placement={isActive ? 'cur' : i < index ? 'answered' : 'scheduled'} + card={card} + className={`card-shadow-2 ${getCardAnimation(i)}`} + opacity={cardOpacity(i)} + showEdit={isOwner && isActive} + showDelete={isOwner && isActive} + /> + ); + })} +
+
+ ); +}; diff --git a/apps/react/src/components/feedback/Confetti.tsx b/apps/react/src/components/feedback/Confetti.tsx new file mode 100644 index 0000000..09c96fa --- /dev/null +++ b/apps/react/src/components/feedback/Confetti.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from 'react'; + +interface ConfettiPiece { + id: number; + left: number; + color: string; + delay: number; + duration: number; +} + +const COLORS = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + +export const Confetti: React.FC<{ show: boolean }> = ({ show }) => { + const [pieces, setPieces] = useState([]); + + useEffect(() => { + if (show) { + const newPieces: ConfettiPiece[] = Array.from({ length: 30 }, (_, i) => ({ + id: i, + left: Math.random() * 100, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + delay: Math.random() * 0.3, + duration: 0.8 + Math.random() * 0.4, + })); + setPieces(newPieces); + const timer = setTimeout(() => setPieces([]), 1500); + return () => clearTimeout(timer); + } + }, [show]); + + if (pieces.length === 0) return null; + + return ( +
+ {pieces.map((piece) => ( +
+ ))} +
+ ); +}; diff --git a/apps/react/src/components/feedback/NetworkStateWrapper.tsx b/apps/react/src/components/feedback/NetworkStateWrapper.tsx new file mode 100644 index 0000000..4601f2c --- /dev/null +++ b/apps/react/src/components/feedback/NetworkStateWrapper.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; +import { Spinner } from './Spinner'; +import { BasicErrorCard } from './ErrorCard'; + +interface NetworkStateWrapperProps { + networkKey: string; + children: React.ReactNode; + showSpinnerWhen?: 'always' | 'no-children'; + hasData?: boolean; +} + +export const NetworkStateWrapper: React.FC = ({ + networkKey, + children, + showSpinnerWhen = 'no-children', + hasData = true, +}) => { + const { isLoading, error } = useNetworkState(networkKey); + const showSpinner = + isLoading && + (showSpinnerWhen === 'always' || (showSpinnerWhen === 'no-children' && !hasData)); + + return ( + <> + + + {children} + + ); +}; diff --git a/apps/react/src/components/feedback/index.ts b/apps/react/src/components/feedback/index.ts index 19cf1e5..797ccf9 100644 --- a/apps/react/src/components/feedback/index.ts +++ b/apps/react/src/components/feedback/index.ts @@ -2,3 +2,4 @@ export * from './Spinner'; export * from './Toast'; export * from './ErrorCard'; export * from './EmptyState'; +export * from './NetworkStateWrapper'; diff --git a/apps/react/src/index.css b/apps/react/src/index.css index 045f75d..fb20b68 100644 --- a/apps/react/src/index.css +++ b/apps/react/src/index.css @@ -122,3 +122,58 @@ body, .dark .recharts-tooltip-label { @apply text-black; } + +/* Card animations */ +@keyframes card-correct { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +@keyframes card-incorrect { + 0%, + 100% { + transform: translateY(0); + } + 20% { + transform: translateY(-4px); + } + 40% { + transform: translateY(4px); + } + 60% { + transform: translateY(-3px); + } + 80% { + transform: translateY(2px); + } +} + +.animate-card-correct { + animation: card-correct 0.3s ease-in-out; +} + +.animate-card-incorrect { + animation: card-incorrect 0.4s ease-in-out; +} + +@keyframes confetti { + 0% { + transform: translateY(0) rotate(0deg) scale(1); + opacity: 1; + } + 100% { + transform: translateY(-150px) rotate(720deg) scale(0); + opacity: 0; + } +} + +.animate-confetti { + animation: confetti 1s ease-out forwards; +} diff --git a/apps/react/src/screens/AllDeckCardsScreen.tsx b/apps/react/src/screens/AllDeckCardsScreen.tsx index f1602fb..3c125db 100644 --- a/apps/react/src/screens/AllDeckCardsScreen.tsx +++ b/apps/react/src/screens/AllDeckCardsScreen.tsx @@ -1,12 +1,10 @@ import { PresentationChartLineIcon } from '@heroicons/react/24/outline'; import React, { useEffect } from 'react'; import { FlashCard, Layout } from '../components'; -import { BasicErrorCard } from '../components/feedback/ErrorCard'; -import { Spinner } from '../components/feedback/Spinner'; +import { NetworkStateWrapper } from '../components/feedback/NetworkStateWrapper'; import { CircleHover } from '../components/ui/CircleHover'; import { getDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-action'; import { currDeckAllWithCorrectAttemptsSortedArray } from 'MemoryFlashCore/src/redux/selectors/currDeckCardsWithAttempts'; -import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; import { useAppDispatch, useAppSelector } from 'MemoryFlashCore/src/redux/store'; import { useDeckIdPath } from './useDeckIdPath'; @@ -17,8 +15,6 @@ export const AllDeckCardsScreen: React.FunctionComponent { if (deckId) { dispatch(getDeck(deckId)); @@ -37,22 +33,22 @@ export const AllDeckCardsScreen: React.FunctionComponent } > - - -
-
{deck.length} cards
- {deck.map((card, i) => ( - - ))} -
+ 0}> +
+
{deck.length} cards
+ {deck.map((card, i) => ( + + ))} +
+
); }; diff --git a/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx b/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx index 2ed3af1..b24596f 100644 --- a/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx +++ b/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx @@ -2,13 +2,11 @@ import { ListBulletIcon } from '@heroicons/react/24/outline'; import React, { useEffect } from 'react'; import { CartesianGrid, Label, Line, LineChart, Tooltip, XAxis, YAxis } from 'recharts'; import { Layout, LinkButton } from '../../components'; -import { BasicErrorCard } from '../../components/feedback/ErrorCard'; -import { Spinner } from '../../components/feedback/Spinner'; +import { NetworkStateWrapper } from '../../components/feedback/NetworkStateWrapper'; import { CircleHover } from '../../components/ui/CircleHover'; import { getDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-action'; import { getStatsDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-stats-action'; import { attemptsStatsSelector } from 'MemoryFlashCore/src/redux/selectors/attemptsStatsSelector'; -import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; import { useAppDispatch, useAppSelector } from 'MemoryFlashCore/src/redux/store'; import { useDeckIdPath } from '../useDeckIdPath'; import { TimeSpentChart } from './TimeSpentStudyingPerDayChart'; @@ -36,7 +34,6 @@ const CustomizedDot: React.FC = (props) => { export const DeckStatsScreen: React.FunctionComponent = ({}) => { const dispatch = useAppDispatch(); const { deckId, deck } = useDeckIdPath(); - const { isLoading, error } = useNetworkState('getDeck' + deckId); useEffect(() => { if (deckId) { dispatch(getDeck(deckId)); @@ -70,75 +67,81 @@ export const DeckStatsScreen: React.FunctionComponent = ({ } > -
- - - -
-
Number of cards: {numCards}
-
Median time to answer: {median.toFixed(1)}s
-
Total time studying: {(totalTimeSpent / 60).toFixed(1)} minutes
- {deckId && ( -
- - View attempt history - -
- )}{' '} -
-
- Median Time Taken to Answer Questions -
- - - ''}> - - +
+
+
Number of cards: {numCards}
+
Median time to answer: {median.toFixed(1)}s
+
Total time studying: {(totalTimeSpent / 60).toFixed(1)} minutes
+ {deckId && ( +
+ + View attempt history + +
+ )}{' '} +
+
+ Median Time Taken to Answer Questions +
+ - { - let payload = data[parseInt(value)]; - let label = new Date(payload.date).toLocaleString(); - return ( - - {/* {payload.highlight && New best!} */} - {/*
*/} - {label} -
- ); - }} - /> - } - /> -
+ > + + ''}> + + + { + let payload = data[parseInt(value)]; + let label = new Date(payload.date).toLocaleString(); + return ( + + {/* {payload.highlight && New best!} */} + {/*
*/} + {label} +
+ ); + }} + /> + } + /> + +
+
-
-
+ ); }; diff --git a/apps/react/src/screens/StudyScreen/StudyScreen.tsx b/apps/react/src/screens/StudyScreen/StudyScreen.tsx index c7cbfbf..06627e6 100644 --- a/apps/react/src/screens/StudyScreen/StudyScreen.tsx +++ b/apps/react/src/screens/StudyScreen/StudyScreen.tsx @@ -1,13 +1,13 @@ import { ListBulletIcon, PresentationChartLineIcon, PlusIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { CircleHover } from '../../components/ui/CircleHover'; -import { FlashCard } from '../../components/FlashCard'; import { Layout } from '../../components/layout/Layout'; import { StudyScreenEmptyState } from './StudyScreenEmptyState'; import { AnswerValidator } from '../../components/answer-validators/AnswerValidator'; import { Keyboard } from '../../components/keyboard/KeyBoard'; import { ChordMemoryDebugDialog } from '../../components/ChordMemoryDebugDialog'; +import { CardCarousel } from '../../components/CardCarousel'; import { getDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-action'; import { schedule } from 'MemoryFlashCore/src/redux/actions/schedule-cards-action'; import { selectActivePresentationMode } from 'MemoryFlashCore/src/redux/selectors/activePresentationModeSelector'; @@ -21,25 +21,16 @@ import { useDeckIdPath } from '../useDeckIdPath'; import { Metronome } from './Metronome'; import { QuestionPresentationModePills } from './QuestionPresentationModePills'; import Timer from './Timer'; -import useWindowResize from './useWindowResize'; import { IS_TEST_ENV } from '../../utils/constants'; -import { isCardOwner } from '../../utils/useIsCardOwner'; export const StudyScreen = () => { const dispatch = useAppDispatch(); - let { cards, index } = useAppSelector(sessionCardsSelector); + const { cards, index } = useAppSelector(sessionCardsSelector); const [hideFutureCards, setHideFutureCards] = useState(false); const attemptsStats = useAppSelector(attemptsStatsSelector); - const { tooLongTime, median } = attemptsStats || { - tooLongTime: 0, - median: 0, - goalTime: 0, - }; + const { tooLongTime, median } = attemptsStats || { tooLongTime: 0, median: 0 }; const { bpm, goalTime } = useAppSelector(bpmSelector); const { deckId, deck } = useDeckIdPath(); - const cardRefs = useRef([]); - const cardContainerRef = useRef(null); - const [cardsTranslation, setCardsTranslation] = useState(''); const activePresentationMode = useAppSelector(selectActivePresentationMode); const { currStartTime } = useAppSelector((state) => state.scheduler); const course = useAppSelector((state) => @@ -49,29 +40,6 @@ export const StudyScreen = () => { const timeSinceCardStart = () => (currStartTime > 0 ? (Date.now() - currStartTime) / 1000 : 0); - const updateTranslation = () => { - let totalWidth = 0; - const cardContainerWidth = cardContainerRef.current?.offsetWidth || 0; - - cardRefs.current.slice(0, index + 1).forEach((ref, forEachIndex) => { - if (!ref) return; - let width = ref?.offsetWidth; - const computedStyle = window.getComputedStyle(ref); - const marginLeft = parseFloat(computedStyle.marginLeft) || 0; - const marginRight = parseFloat(computedStyle.marginRight) || 0; - width += marginLeft + marginRight; - - if (forEachIndex === index) { - totalWidth += width / 2 || 0; - } else { - totalWidth += width || 0; - } - }); - - const translation = cardContainerWidth / 2 - totalWidth; - setCardsTranslation(`translateX(${translation}px)`); - }; - useEffect(() => { if (deckId) { dispatch(getDeck(deckId)).then(() => { @@ -82,50 +50,17 @@ export const StudyScreen = () => { useEffect(() => { if (tooLongTime <= 0) return; - const clear = setTimeout( - () => { - setHideFutureCards(true); - }, + const timer = setTimeout( + () => setHideFutureCards(true), (tooLongTime - timeSinceCardStart()) * 1000, ); - return () => clearTimeout(clear); + return () => clearTimeout(timer); }, [cards[index], hideFutureCards, tooLongTime]); useEffect(() => { - if (hideFutureCards) { - setHideFutureCards(false); - } + if (hideFutureCards) setHideFutureCards(false); }, [index]); - useEffect(() => { - cardRefs.current = cardRefs.current.slice(0, cards.length); - }, [cards.length]); - - useWindowResize(() => { - updateTranslation(); - }); - - const cardOpactity = (_index: number) => { - if (_index === index) { - return 1; - } else if (_index > index && hideFutureCards) { - return 0; - } else if (_index === index + 1) { - return 0.75; - } else { - return 0.4; - } - }; - - useLayoutEffect(() => { - // the music rendering takes a little longer - setTimeout(() => { - updateTranslation(); - }, 1000 / 30); - }, [cards.length, index, activePresentationMode]); - - // console.log(JSON.stringify(cards, undefined, 4)); - return ( { subtitle={course && deck && `${course?.name} ยท ${deck?.name}`} > -
-
- {cards.map((card, i) => { - const isOwner = isCardOwner(card, user); - const isActive = i === index; - return ( - (cardRefs.current[i] = el!)} - placement={isActive ? 'cur' : i < index ? 'answered' : 'scheduled'} - card={card} - className={`card-shadow-2 ${cardOpactity(i)}`} - opacity={cardOpactity(i)} - showEdit={isOwner && isActive} - showDelete={isOwner && isActive} - /> - ); - })} -
-
+
{cards[index] && } diff --git a/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx b/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx index db3ff69..364f245 100644 --- a/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx +++ b/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx @@ -1,16 +1,13 @@ import { Link } from 'react-router-dom'; import { Button } from '../../components/ui/Button'; -import { BasicErrorCard } from '../../components/feedback/ErrorCard'; -import { Spinner } from '../../components/feedback/Spinner'; +import { NetworkStateWrapper } from '../../components/feedback/NetworkStateWrapper'; import { useDeckIdPath } from '../useDeckIdPath'; -import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; import { useAppSelector } from 'MemoryFlashCore/src/redux/store'; import { sessionCardsSelector } from 'MemoryFlashCore/src/redux/selectors/scheduledCardsSelector'; export const StudyScreenEmptyState: React.FC = () => { const { deckId, deck } = useDeckIdPath(); const { cards } = useAppSelector(sessionCardsSelector); - const { isLoading, error } = useNetworkState('getDeck' + deckId); const course = useAppSelector((state) => deck?.courseId ? state.courses.entities[deck.courseId] : undefined, ); @@ -19,17 +16,14 @@ export const StudyScreenEmptyState: React.FC = () => { if (cards.length > 0) return null; return ( - <> - - - {!isLoading && - (isOwner ? ( - - - - ) : ( -
This deck has no cards.
- ))} - + + {isOwner ? ( + + + + ) : ( +
This deck has no cards.
+ )} +
); }; From 784f752ee829a26273aadfab205e1eef862f9cc7 Mon Sep 17 00:00:00 2001 From: Sam Bender <2336186+rednebmas@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:27:37 -0800 Subject: [PATCH 2/2] ability to click virtual keyboard --- .../AnyOctaveAnswerValidator.tsx | 2 + .../src/components/keyboard/KeyBoard.tsx | 9 ++- .../src/lib/ChordMemoryValidatorEngine.ts | 1 + .../src/lib/ValidatorEngine.ts | 16 ++--- .../src/redux/slices/midiSlice.test.ts | 40 ++++++++++++ .../src/redux/slices/midiSlice.ts | 61 ++++++++++++++----- 6 files changed, 103 insertions(+), 26 deletions(-) create mode 100644 packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts diff --git a/apps/react/src/components/answer-validators/AnyOctaveAnswerValidator.tsx b/apps/react/src/components/answer-validators/AnyOctaveAnswerValidator.tsx index f2deb50..2552ee8 100644 --- a/apps/react/src/components/answer-validators/AnyOctaveAnswerValidator.tsx +++ b/apps/react/src/components/answer-validators/AnyOctaveAnswerValidator.tsx @@ -19,11 +19,13 @@ export const AnyOctaveAnswerValidator: React.FC<{ card: Card }> = ({ card }) => if (!answerNotesChroma.includes(onNotesChroma[i])) { dispatch(recordAttempt(false)); dispatch(midiActions.addWrongNote(onNotes[i].number)); + dispatch(midiActions.waitUntilEmpty()); return; } } if (onNotes.length === answer.notes.length) { + dispatch(midiActions.requestClearClickedNotes()); dispatch(recordAttempt(true)); console.log('Correct!'); } diff --git a/apps/react/src/components/keyboard/KeyBoard.tsx b/apps/react/src/components/keyboard/KeyBoard.tsx index d08b0fc..b9384e0 100644 --- a/apps/react/src/components/keyboard/KeyBoard.tsx +++ b/apps/react/src/components/keyboard/KeyBoard.tsx @@ -50,6 +50,7 @@ const CustomisedKey: React.FC<{ const pressed = onNotes.find((note) => note.number === midi) !== undefined; const { cards, index } = useAppSelector(sessionCardsSelector); const card = cards[index]; + const pendingClear = useAppSelector((state) => state.midi.pendingClearClickedNotes); const noteIsCorrect = !useAppSelector((state) => state.midi.wrongNotes).includes(midi); let whiteKeyStartColor = '#FFF'; @@ -81,12 +82,16 @@ const CustomisedKey: React.FC<{ } }} onMouseUp={() => { - if (!noteIsCorrect || !card) { + // Remove on mouse up if: + // 1. It's a wrong note (so user can fix mistakes), OR + // 2. Chord was just completed (pendingClear is true) + if (pressed && (!noteIsCorrect || pendingClear)) { dispatch(midiActions.removeNote(midi)); } }} onMouseLeave={() => { - if ((pressed && !noteIsCorrect) || !card) { + // Only remove wrong notes on mouse leave + if (pressed && !noteIsCorrect) { dispatch(midiActions.removeNote(midi)); } }} diff --git a/packages/MemoryFlashCore/src/lib/ChordMemoryValidatorEngine.ts b/packages/MemoryFlashCore/src/lib/ChordMemoryValidatorEngine.ts index 2d15f1a..b69bef6 100644 --- a/packages/MemoryFlashCore/src/lib/ChordMemoryValidatorEngine.ts +++ b/packages/MemoryFlashCore/src/lib/ChordMemoryValidatorEngine.ts @@ -82,6 +82,7 @@ export class ChordMemoryValidatorEngine { } private onCorrect(index: number, dispatch: AppDispatch): void { + dispatch(midiActions.requestClearClickedNotes()); dispatch(midiActions.waitUntilEmpty()); const nextIndex = index + 1; if (nextIndex >= this.chords.length) { diff --git a/packages/MemoryFlashCore/src/lib/ValidatorEngine.ts b/packages/MemoryFlashCore/src/lib/ValidatorEngine.ts index 092e245..8dc48a7 100644 --- a/packages/MemoryFlashCore/src/lib/ValidatorEngine.ts +++ b/packages/MemoryFlashCore/src/lib/ValidatorEngine.ts @@ -35,7 +35,7 @@ export class ValidatorEngine { if (this.isCorrect(projected)) { this.onCorrect(index, dispatch); } else if (this.hasWrongNotes(projected.added, projected.expectedOnBeat)) { - this.onWrong(projected.added, projected.expectedAdded, onNotes, dispatch); + this.onWrong(projected.added, projected.expectedOnBeat, added, dispatch); } } @@ -98,21 +98,21 @@ export class ValidatorEngine { } private onWrong( - onNotesProjected: number[], - expectedProjected: number[], - onNotesRaw: number[], + addedProjected: number[], + expectedOnBeat: number[], + addedRaw: number[], dispatch: AppDispatch, ): void { - console.log('[validation] onWrong', onNotesProjected, expectedProjected); + console.log('[validation] onWrong', addedProjected, expectedOnBeat); dispatch(recordAttempt(false)); - const wrong = onNotesProjected.find((n) => !expectedProjected.includes(n)); - if (typeof wrong === 'number') - dispatch(midiActions.addWrongNote(onNotesRaw[onNotesProjected.indexOf(wrong)])); + const wrongIdx = addedProjected.findIndex((n) => !expectedOnBeat.includes(n)); + if (wrongIdx !== -1) dispatch(midiActions.addWrongNote(addedRaw[wrongIdx])); dispatch(midiActions.waitUntilEmpty()); } private onCorrect(index: number, dispatch: AppDispatch): void { + dispatch(midiActions.requestClearClickedNotes()); dispatch(midiActions.waitUntilEmpty()); const nextIndex = index + 1; if (nextIndex + 1 == this.timeline.beats.length) { diff --git a/packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts b/packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts new file mode 100644 index 0000000..137a14f --- /dev/null +++ b/packages/MemoryFlashCore/src/redux/slices/midiSlice.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { midiReducer, midiActions, MidiReduxState } from './midiSlice'; + +const initialState: MidiReduxState = { + notes: [], + wrongNotes: [], + waitingUntilEmpty: false, + waitingUntilEmptyNotes: [], + availableMidiDevices: [], + pendingClearClickedNotes: false, +}; + +describe('midiSlice', () => { + describe('on-screen keyboard clicking behavior', () => { + it('defers clearing clicked notes until mouse up via requestClearClickedNotes', () => { + // Simulate: user clicks notes to complete a chord + let state = midiReducer( + initialState, + midiActions.addNote({ number: 60, clicked: true }), + ); + state = midiReducer(state, midiActions.addNote({ number: 64, clicked: true })); + expect(state.notes.length).to.equal(2); + + // Validator detects correct chord and requests clear (not immediate clear) + state = midiReducer(state, midiActions.requestClearClickedNotes()); + + // Notes should still be there (user hasn't released mouse yet) + expect(state.notes.length).to.equal(2); + expect(state.pendingClearClickedNotes).to.be.true; + + // User releases mouse (removes one clicked note) + state = midiReducer(state, midiActions.removeNote(64)); + + // Now all clicked notes should be cleared + expect(state.notes.length).to.equal(0); + expect(state.pendingClearClickedNotes).to.be.false; + }); + }); +}); diff --git a/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts b/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts index 36b69da..29d986c 100644 --- a/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts +++ b/packages/MemoryFlashCore/src/redux/slices/midiSlice.ts @@ -20,6 +20,7 @@ export interface MidiReduxState { availableMidiDevices: MidiInput[]; selectedInput?: string; selectedOutput?: string; + pendingClearClickedNotes: boolean; } const initialState: MidiReduxState = { @@ -28,6 +29,7 @@ const initialState: MidiReduxState = { waitingUntilEmpty: false, waitingUntilEmptyNotes: [], availableMidiDevices: [], + pendingClearClickedNotes: false, }; const midiSlice = createSlice({ @@ -50,12 +52,33 @@ const midiSlice = createSlice({ ); }, removeNote(state, action: PayloadAction) { + const wasWrongNote = state.wrongNotes.includes(action.payload); + const wasClicked = state.notes.find((n) => n.number === action.payload)?.clicked; state.notes = state.notes.filter((note) => note.number !== action.payload); state.wrongNotes = state.wrongNotes.filter((note) => note !== action.payload); state.waitingUntilEmptyNotes = state.waitingUntilEmptyNotes.filter( (note) => note.number !== action.payload, ); - if (state.waitingUntilEmpty && state.waitingUntilEmptyNotes.length === 0) { + + // If pending clear and a clicked note was released, clear all clicked notes + if (state.pendingClearClickedNotes && wasClicked) { + const clickedNotes = state.notes.filter((note) => note.clicked); + state.wrongNotes = state.wrongNotes.filter( + (wn) => !clickedNotes.find((n) => n.number === wn), + ); + state.notes = state.notes.filter((note) => !note.clicked); + state.waitingUntilEmptyNotes = state.waitingUntilEmptyNotes.filter( + (note) => !note.clicked, + ); + state.pendingClearClickedNotes = false; + } + + // Clear waiting if no notes left, OR if we just removed a wrong note and no wrong notes remain + if ( + state.waitingUntilEmpty && + (state.waitingUntilEmptyNotes.length === 0 || + (wasWrongNote && state.wrongNotes.length === 0)) + ) { state.waitingUntilEmpty = false; } @@ -100,27 +123,33 @@ const midiSlice = createSlice({ state.selectedOutput = action.payload; }, waitUntilEmpty(state) { - state.waitingUntilEmpty = true; - state.waitingUntilEmptyNotes = state.notes; + if (state.notes.length === 0) { + state.waitingUntilEmpty = false; + state.waitingUntilEmptyNotes = []; + } else { + state.waitingUntilEmpty = true; + state.waitingUntilEmptyNotes = state.notes; + } }, - - // ok the thing with the click keyboard is - // you want to clear clicked notes after key press is UP - // that's when you want to remove - // unforntunately, waitUntilEmpty must also be called when a wrong note appears - // but we don't clear clicked - - /* clearClickedNotes(state) { + const clickedNotes = state.notes.filter((note) => note.clicked); state.wrongNotes = state.wrongNotes.filter( - (wn) => !state.notes.find((n) => n.number === wn)?.clicked, + (wn) => !clickedNotes.find((n) => n.number === wn), ); state.notes = state.notes.filter((note) => !note.clicked); - // if (state.notes.length === 0 && state.waitingUntilEmpty) { - // state.waitingUntilEmpty = false; - // } + state.waitingUntilEmptyNotes = state.waitingUntilEmptyNotes.filter( + (note) => !note.clicked, + ); + if (state.waitingUntilEmpty && state.waitingUntilEmptyNotes.length === 0) { + state.waitingUntilEmpty = false; + } + state.pendingClearClickedNotes = false; + }, + requestClearClickedNotes(state) { + // Set flag to clear clicked notes on next mouse up + // This allows the user to see the correct notes before they disappear + state.pendingClearClickedNotes = true; }, - */ }, });