Skip to content

Commit 922b13e

Browse files
ChengaDevclaude
andcommitted
Add Reaction Time Training mode (behind feature flag)
Full gamified shot clock training feature at /reaction-training: - 12 FIBA basketball scenarios, two-phase design (read context → timed flash) - Easy/Medium/Pro difficulty with per-scenario countdown - 1-minute session timer with live HUD (score, streak, time remaining) - Scoring with speed bonus, streak bonus, difficulty multiplier - Local Hall of Fame via localStorage (API-swappable) - Session results with personal best detection and grade breakdown - All UI chrome translated into 5 languages; scenario text in English Gated behind REACT_APP_TRAINING_ENABLED=true — invisible in prod until the flag is set in the deployment environment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ceec27a commit 922b13e

20 files changed

Lines changed: 2053 additions & 1 deletion

src/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useState, useEffect, lazy, Suspense } from 'react'
2+
import featureFlags from './featureFlags'
23
import { BrowserRouter as Router, Routes, Route, useLocation, useParams, Outlet, useNavigate } from 'react-router-dom'
34
import styled, { ThemeProvider } from 'styled-components'
45
import LanguageProvider, { useLocalization } from './contexts/Language/LanguageProvider'
@@ -18,6 +19,7 @@ const FIBAResources = lazy(() => import('./components/FIBAResources'))
1819
const FAQ = lazy(() => import('./components/FAQ'))
1920
const PrivacyPolicy = lazy(() => import('./components/PrivacyPolicy'))
2021
const NotFound = lazy(() => import('./components/NotFound'))
22+
const ReactionTrainingPage = lazy(() => import('./components/training/ReactionTrainingPage'))
2123

2224
const NON_ENGLISH_LANGS = ['it', 'es', 'fr', 'el']
2325

@@ -125,6 +127,13 @@ const pageRoutes = (
125127
<PrivacyPolicy />
126128
</PageContent>
127129
} />
130+
{featureFlags.reactionTraining && (
131+
<Route path="reaction-training" element={
132+
<PageContent title="Reaction Training - ShotClock Pro">
133+
<ReactionTrainingPage />
134+
</PageContent>
135+
} />
136+
)}
128137
</>
129138
)
130139

src/AppRoutes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const AppRoutes = (lang: string) => ({
44
FIBAResources: lang === 'en' ? '/fiba-resources' : `/${lang}/fiba-resources`,
55
About: lang === 'en' ? '/about' : `/${lang}/about`,
66
FAQ: lang === 'en' ? '/faq' : `/${lang}/faq`,
7+
ReactionTraining: lang === 'en' ? '/reaction-training' : `/${lang}/reaction-training`,
78
})
89

910
export default AppRoutes

src/components/Footer.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import DonateButton from './DonateButton'
44
import LanguageSelector from './LanguageSelector'
55
import { useLocalization } from '../contexts/Language/LanguageProvider'
66
import AppRoutes from '../AppRoutes'
7+
import featureFlags from '../featureFlags'
78

89
const Footer = () => {
910
const { locals, languageCode } = useLocalization()
@@ -26,6 +27,9 @@ const Footer = () => {
2627
<FooterNavLink to={routes.FIBAResources}>{locals.fibaResources}</FooterNavLink>
2728
<FooterNavLink to={routes.FAQ}>{locals.faq}</FooterNavLink>
2829
<FooterNavLink to={routes.About}>{locals.about}</FooterNavLink>
30+
{featureFlags.reactionTraining && (
31+
<FooterNavLink to={routes.ReactionTraining}>{locals.reactionTraining}</FooterNavLink>
32+
)}
2933
<FooterNavLink to="/privacy-policy">Privacy Policy</FooterNavLink>
3034
</FooterLinks>
3135
</FooterSection>

src/components/Navigation.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AppRoutes from '../AppRoutes'
44
import { Link, useLocation } from 'react-router-dom'
55
import { useLocalization } from '../contexts/Language/LanguageProvider'
66
import { useState, useRef, useEffect } from 'react'
7+
import featureFlags from '../featureFlags'
78

89
type NavigationProps = {
910
currentTheme: string
@@ -86,6 +87,15 @@ const Navigation = ({ currentTheme, setTheme }: NavigationProps) => {
8687
>
8788
{locals.faq}
8889
</NavLink>
90+
{featureFlags.reactionTraining && (
91+
<NavLink
92+
to={routes.ReactionTraining}
93+
$active={location.pathname === routes.ReactionTraining}
94+
onClick={handleNavClick}
95+
>
96+
{locals.reactionTraining}
97+
</NavLink>
98+
)}
8999
<ThemeToggle>
90100
<ThemeButton
91101
$active={currentTheme === Themes.Light}

src/components/SeeAlso.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import styled from 'styled-components'
33
import { Link } from 'react-router-dom'
44
import { useLocalization } from '../contexts/Language/LanguageProvider'
55
import AppRoutes from '../AppRoutes'
6+
import featureFlags from '../featureFlags'
67

78
interface SeeAlsoProps {
8-
exclude?: ('instructions' | 'faq' | 'fiba-resources')[]
9+
exclude?: ('instructions' | 'faq' | 'fiba-resources' | 'reaction-training')[]
910
}
1011

1112
const SeeAlso: React.FC<SeeAlsoProps> = ({ exclude = [] }) => {
@@ -16,6 +17,9 @@ const SeeAlso: React.FC<SeeAlsoProps> = ({ exclude = [] }) => {
1617
{ key: 'instructions', label: locals.instructions, to: routes.Instructions },
1718
{ key: 'faq', label: locals.faq, to: routes.FAQ },
1819
{ key: 'fiba-resources', label: locals.fibaResources, to: routes.FIBAResources },
20+
...(featureFlags.reactionTraining
21+
? [{ key: 'reaction-training', label: locals.reactionTraining, to: routes.ReactionTraining }]
22+
: []),
1923
].filter(l => !exclude.includes(l.key as any))
2024

2125
return (
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import React from 'react'
2+
import styled, { keyframes } from 'styled-components'
3+
import { ScenarioResult, Scenario } from '../../data/trainingScenarios'
4+
import { useLocalization } from '../../contexts/Language/LanguageProvider'
5+
6+
interface FeedbackOverlayProps {
7+
result: ScenarioResult
8+
scenario: Scenario
9+
onNext: () => void
10+
isLastBeforeResults?: boolean
11+
}
12+
13+
const FeedbackOverlay: React.FC<FeedbackOverlayProps> = ({
14+
result,
15+
scenario,
16+
onNext,
17+
isLastBeforeResults,
18+
}) => {
19+
const { locals } = useLocalization()
20+
const gradeLabel = locals.gradeLabels[result.grade.toLowerCase().replace('+', 'p') as keyof typeof locals.gradeLabels]
21+
|| locals.gradeLabels[result.grade === 'A+' ? 'ap' : result.grade.toLowerCase() as keyof typeof locals.gradeLabels]
22+
23+
return (
24+
<Container>
25+
<ResultBadge $correct={result.correct}>
26+
{result.reactionMs === 0 ? locals.timesUp : result.correct ? locals.correctAnswer : locals.wrongAnswer}
27+
</ResultBadge>
28+
29+
{result.correct && (
30+
<StatsRow>
31+
<Stat>
32+
<StatLabel>{locals.reactionTime}</StatLabel>
33+
<StatValue>{result.reactionMs}{locals.milliseconds}</StatValue>
34+
</Stat>
35+
<Stat>
36+
<StatLabel>Grade</StatLabel>
37+
<GradeValue $grade={result.grade}>{result.grade}{gradeLabel}</GradeValue>
38+
</Stat>
39+
{result.score > 0 && (
40+
<Stat>
41+
<PointsEarned>
42+
{locals.pointsEarned.replace('{0}', String(result.score))}
43+
</PointsEarned>
44+
</Stat>
45+
)}
46+
</StatsRow>
47+
)}
48+
49+
<ExplanationBox>
50+
<ExplanationLabel>{locals.explanationLabel}</ExplanationLabel>
51+
<ExplanationText>{scenario.explanation}</ExplanationText>
52+
</ExplanationBox>
53+
54+
<NextButton onClick={onNext}>
55+
{isLastBeforeResults ? 'See Results' : locals.nextScenario}
56+
</NextButton>
57+
</Container>
58+
)
59+
}
60+
61+
const fadeSlide = keyframes`
62+
from { opacity: 0; transform: translateY(16px); }
63+
to { opacity: 1; transform: translateY(0); }
64+
`
65+
66+
const Container = styled.div`
67+
display: flex;
68+
flex-direction: column;
69+
align-items: center;
70+
gap: 1.25rem;
71+
animation: ${fadeSlide} 0.3s ease-out;
72+
max-width: 600px;
73+
margin: 0 auto;
74+
width: 100%;
75+
`
76+
77+
const ResultBadge = styled.div<{ $correct: boolean }>`
78+
font-size: 1.8rem;
79+
font-weight: 900;
80+
font-family: 'Poppins', sans-serif;
81+
color: ${props => props.$correct ? '#4CAF50' : '#ff6b6b'};
82+
text-shadow: 0 2px 8px ${props => props.$correct ? 'rgba(76,175,80,0.3)' : 'rgba(255,107,107,0.3)'};
83+
`
84+
85+
const StatsRow = styled.div`
86+
display: flex;
87+
gap: 1.5rem;
88+
flex-wrap: wrap;
89+
justify-content: center;
90+
`
91+
92+
const Stat = styled.div`
93+
display: flex;
94+
flex-direction: column;
95+
align-items: center;
96+
gap: 0.2rem;
97+
`
98+
99+
const StatLabel = styled.span`
100+
font-size: 0.7rem;
101+
color: ${props => props.theme.subtleText};
102+
text-transform: uppercase;
103+
letter-spacing: 0.08em;
104+
`
105+
106+
const StatValue = styled.span`
107+
font-size: 1.1rem;
108+
font-weight: 700;
109+
color: ${props => props.theme.titleColor};
110+
`
111+
112+
const gradeColor = (grade: string) => {
113+
if (grade === 'A+' || grade === 'A') return '#4CAF50'
114+
if (grade === 'B') return '#8BC34A'
115+
if (grade === 'C') return '#FF9800'
116+
if (grade === 'D') return '#FF5722'
117+
return '#ff6b6b'
118+
}
119+
120+
const GradeValue = styled.span<{ $grade: string }>`
121+
font-size: 1rem;
122+
font-weight: 700;
123+
color: ${props => gradeColor(props.$grade)};
124+
`
125+
126+
const PointsEarned = styled.span`
127+
font-size: 1.4rem;
128+
font-weight: 900;
129+
background: linear-gradient(135deg, #ffd700, #ff9800);
130+
-webkit-background-clip: text;
131+
-webkit-text-fill-color: transparent;
132+
`
133+
134+
const ExplanationBox = styled.div`
135+
background: ${props => props.theme.cardBackground};
136+
border: 1px solid ${props => props.theme.cardBorder};
137+
border-radius: 12px;
138+
padding: 1.25rem 1.5rem;
139+
width: 100%;
140+
`
141+
142+
const ExplanationLabel = styled.div`
143+
font-size: 0.75rem;
144+
font-weight: 700;
145+
color: ${props => props.theme.accent};
146+
text-transform: uppercase;
147+
letter-spacing: 0.1em;
148+
margin-bottom: 0.5rem;
149+
`
150+
151+
const ExplanationText = styled.p`
152+
margin: 0;
153+
font-size: 0.95rem;
154+
line-height: 1.7;
155+
color: ${props => props.theme.cardText};
156+
`
157+
158+
const NextButton = styled.button`
159+
background: linear-gradient(135deg, #ffd700, #ff9800);
160+
color: #1a1a1a;
161+
border: none;
162+
border-radius: 12px;
163+
padding: 0.85rem 2.5rem;
164+
font-size: 1rem;
165+
font-weight: 700;
166+
cursor: pointer;
167+
transition: all 0.2s ease;
168+
font-family: 'Poppins', sans-serif;
169+
170+
&:hover {
171+
transform: translateY(-2px);
172+
box-shadow: 0 6px 20px rgba(255, 215, 0, 0.4);
173+
}
174+
`
175+
176+
export default FeedbackOverlay
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react'
2+
import styled from 'styled-components'
3+
import { HallOfFameEntry } from '../../services/hallOfFameService'
4+
import { useLocalization } from '../../contexts/Language/LanguageProvider'
5+
6+
interface HallOfFameTableProps {
7+
entries: HallOfFameEntry[]
8+
highlightScore?: number
9+
}
10+
11+
const HallOfFameTable: React.FC<HallOfFameTableProps> = ({ entries, highlightScore }) => {
12+
const { locals } = useLocalization()
13+
14+
if (entries.length === 0) {
15+
return (
16+
<EmptyMessage>No entries yet. Be the first to make the Hall of Fame!</EmptyMessage>
17+
)
18+
}
19+
20+
return (
21+
<Table>
22+
<thead>
23+
<tr>
24+
<Th>#</Th>
25+
<Th>Initials</Th>
26+
<Th>Score</Th>
27+
<Th>Grade</Th>
28+
<Th>Difficulty</Th>
29+
<Th>Date</Th>
30+
</tr>
31+
</thead>
32+
<tbody>
33+
{entries.map((entry, index) => {
34+
const isNew = highlightScore !== undefined && entry.score === highlightScore
35+
return (
36+
<Tr key={index} $highlight={isNew}>
37+
<Td>{index + 1}</Td>
38+
<TdInitials>{entry.initials}</TdInitials>
39+
<TdScore>{entry.score}</TdScore>
40+
<Td>{entry.grade}</Td>
41+
<TdDifficulty $difficulty={entry.difficulty}>{entry.difficulty}</TdDifficulty>
42+
<Td>{new Date(entry.date).toLocaleDateString()}</Td>
43+
</Tr>
44+
)
45+
})}
46+
</tbody>
47+
</Table>
48+
)
49+
}
50+
51+
const EmptyMessage = styled.p`
52+
text-align: center;
53+
color: ${props => props.theme.subtleText};
54+
font-size: 0.95rem;
55+
padding: 1rem;
56+
`
57+
58+
const Table = styled.table`
59+
width: 100%;
60+
border-collapse: collapse;
61+
font-size: 0.9rem;
62+
`
63+
64+
const Th = styled.th`
65+
text-align: left;
66+
padding: 0.6rem 0.75rem;
67+
color: ${props => props.theme.subtleText};
68+
font-weight: 600;
69+
font-size: 0.8rem;
70+
text-transform: uppercase;
71+
letter-spacing: 0.05em;
72+
border-bottom: 1px solid ${props => props.theme.cardBorder};
73+
`
74+
75+
const Tr = styled.tr<{ $highlight?: boolean }>`
76+
background: ${props => props.$highlight ? 'rgba(255, 215, 0, 0.12)' : 'transparent'};
77+
transition: background 0.2s ease;
78+
79+
&:hover {
80+
background: ${props => props.$highlight ? 'rgba(255, 215, 0, 0.18)' : props.theme.cardBackground};
81+
}
82+
`
83+
84+
const Td = styled.td`
85+
padding: 0.6rem 0.75rem;
86+
color: ${props => props.theme.cardText};
87+
border-bottom: 1px solid ${props => props.theme.cardBorder}55;
88+
`
89+
90+
const TdInitials = styled(Td)`
91+
font-family: 'DSEG14ClassicRegular', monospace;
92+
font-weight: 700;
93+
color: ${props => props.theme.accent};
94+
letter-spacing: 0.1em;
95+
`
96+
97+
const TdScore = styled(Td)`
98+
font-weight: 700;
99+
color: ${props => props.theme.titleColor};
100+
font-size: 1rem;
101+
`
102+
103+
const TdDifficulty = styled(Td)<{ $difficulty: string }>`
104+
text-transform: capitalize;
105+
color: ${props =>
106+
props.$difficulty === 'pro' ? '#ff6b6b' :
107+
props.$difficulty === 'medium' ? '#FF9800' :
108+
'#4CAF50'
109+
};
110+
font-weight: 600;
111+
`
112+
113+
export default HallOfFameTable

0 commit comments

Comments
 (0)