@ -63,18 +63,17 @@ issues:
// .eslintrc.json
// .eslintrc.json
{
{
"extends": [
"extends": [
"@react-native ",
"plugin:solid/typescript ",
"@typescript-eslint/recommended",
"@typescript-eslint/recommended",
"prettier"
"prettier"
],
],
"parser": "@typescript-eslint/parser",
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react-hooks "],
"plugins": ["@typescript-eslint", "solid "],
"rules": {
"rules": {
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/prefer-const": "error",
"@typescript-eslint/prefer-const": "error",
"react-hooks/rules-of-hooks": "error",
"solid/reactivity": "warn",
"react-hooks/exhaustive-deps": "warn",
"prefer-const": "error",
"prefer-const": "error",
"no-var": "error",
"no-var": "error",
"object-shorthand": "error",
"object-shorthand": "error",
@ -129,14 +128,14 @@ type ScoreCalculator interface {}
// Files: kebab-case
// Files: kebab-case
game-card.tsx
game-card.tsx
user-service.ts
user-service.ts
auth-context.tsx
auth-store.ts
// Components: PascalCase
// Components: PascalCase
export const GameCard: React.FC < GameCardProps > = () => {}
export const GameCard: Component < GameCardProps > = () => {}
export const LeaderboardList: React.FC = () => {}
export const LeaderboardList: Component = () => {}
// Hooks: camelCase with "use" prefix
// Composables/stores: camelCase with "create"/ "use" prefix
export const useGameSession = () => {}
export const createGameSessionStore = () => {}
export const useAuthentication = () => {}
export const useAuthentication = () => {}
// Functions: camelCase
// Functions: camelCase
@ -608,24 +607,21 @@ func (s *GameService) calculateTimeRemaining(startTime time.Time) int {
## TypeScript Development Standards
## TypeScript Development Standards
### React Component Patterns
### SolidJS Component Patterns
#### Component Structure
#### Component Structure
```typescript
```typescript
// Component with proper TypeScript types and patterns
// Component with proper TypeScript types and SolidJS patterns
import React, { useState, useEffect, useCallback } from 'react'
import type { Component } from 'solid-js'
import { createMemo, createSignal, Show } from 'solid-js'
import {
import {
Card,
Box,
VStack,
HStack,
Text,
Input,
Button,
Button,
Badge ,
Input,
Progress
LinearProgress,
} from '@gluestack-ui/themed'
Typography,
import { useGameSession } from '../hooks/useGameSession'
Chip,
import { useTimer } from '../hooks/useTimer '
} from '@suid/material '
// Props interface with proper documentation
// Props interface with proper documentation
export interface GameCardProps {
export interface GameCardProps {
@ -637,346 +633,99 @@ export interface GameCardProps {
timeRemaining: number
timeRemaining: number
/** Number of attempts left */
/** Number of attempts left */
attemptsLeft: number
attemptsLeft: number
/** Current player score */
currentScore: number
/** Loading state */
isLoading?: boolean
/** Callback when answer is submitted */
/** Callback when answer is submitted */
onSubmitAnswer: (answer: string) => Promise< void >
onSubmitAnswer: (answer: string) => Promise< void >
/** Callback when hint is requested */
/** Loading state from async operations */
onRequestHint: () => Promise< void >
loading?: boolean
/** Callback when time expires */
onTimeExpire?: () => void
}
}
/**
/**
* GameCard component displays a quiz question with input and controls
* GameCard component displays a quiz question with input and controls
*
*
* @param props - GameCard props
* @param props - GameCard props
* @returns React component
* @returns SolidJS component
*/
*/
export const GameCard: React.FC< GameCardProps > = ({
export const GameCard: Component< GameCardProps > = (props) => {
question,
const [answer, setAnswer] = createSignal('')
theme,
const [isSubmitting, setIsSubmitting] = createSignal(false)
timeRemaining,
attemptsLeft,
const disabled = createMemo(() => props.loading || isSubmitting())
currentScore,
const progressValue = createMemo(() => ((30 * 60 - props.timeRemaining) / (30 * 60)) * 100)
isLoading = false,
onSubmitAnswer,
const handleSubmit = async () => {
onRequestHint,
const value = answer().trim()
onTimeExpire
if (!value || disabled()) return
}) => {
// Local state with proper typing
const [answer, setAnswer] = useState< string > ('')
const [isSubmitting, setIsSubmitting] = useState< boolean > (false)
// Custom hooks
const { formatTime } = useTimer()
// Memoized handlers
const handleSubmit = useCallback(async () => {
if (!answer.trim() || isSubmitting) return
setIsSubmitting(true)
setIsSubmitting(true)
try {
try {
await onSubmitAnswer(answer.trim() )
await props.onSubmitAnswer(value)
setAnswer('') // Clear input on successful submission
setAnswer('')
} catch (error) {
} catch (error) {
console.error('Failed to submit answer:', error)
console.error('Failed to submit answer:', error)
// Error handling would be managed by parent component
} finally {
} finally {
setIsSubmitting(false)
setIsSubmitting(false)
}
}
}, [answer, isSubmitting, onSubmitAnswer])
}
const handleHintRequest = useCallback(async () => {
if (isLoading) return
try {
await onRequestHint()
} catch (error) {
console.error('Failed to request hint:', error)
}
}, [isLoading, onRequestHint])
// Effects
useEffect(() => {
if (timeRemaining === 0 & & onTimeExpire) {
onTimeExpire()
}
}, [timeRemaining, onTimeExpire])
// Computed values
const progressValue = ((30 * 60 - timeRemaining) / (30 * 60)) * 100
const isTimeWarning = timeRemaining < = 300 // 5 minutes
const isTimeCritical = timeRemaining < = 60 // 1 minute
return (
return (
< Card size = "lg" variant = "elevated" m = "$4" >
< Box sx = {{ p: 3 , border: ' 1px solid ' , borderColor: ' divider ' , borderRadius: 2 } } >
< VStack space = "md" p = "$4" >
< Box sx = {{ display: ' flex ' , justifyContent: ' space-between ' , mb: 1 } } >
{/* Header with theme and timer */}
< Chip label = {props.theme} / >
< HStack justifyContent = "space-between" alignItems = "center" >
< Typography variant = "body2" > {props.timeRemaining}s< / Typography >
< Badge
< / Box >
size="sm"
< LinearProgress variant = "determinate" value = {progressValue()} / >
variant="solid"
< Typography variant = "h6" sx = {{ mt: 2 } } > {props.question}< / Typography >
action="info"
< Typography variant = "body2" sx = {{ mt: 1 } } > Attempts left: {props.attemptsLeft}/3< / Typography >
testID="theme-badge"
< Input
>
value={answer()}
< Text color = "$white" fontSize = "$sm" fontWeight = "$semibold" >
onInput={(e) => setAnswer(e.currentTarget.value)}
{theme}
disabled={disabled()}
< / Text >
placeholder="Enter your answer"
< / Badge >
/>
< HStack space = "sm" alignItems = "center" >
< Button onClick = {handleSubmit} disabled = {disabled() | | answer ( ) . trim ( ) . length = == 0 } >
< Text
Submit Answer
fontSize="$sm"
< / Button >
color={isTimeCritical ? "$error500" : isTimeWarning ? "$warning500" : "$textLight600"}
< Show when = {disabled()} >
testID="timer-display"
< Typography variant = "caption" > Submitting...< / Typography >
>
< / Show >
⏱️ {formatTime(timeRemaining)}
< / Box >
< / Text >
< / HStack >
< / HStack >
{/* Progress indicator */}
< Progress
value={progressValue}
size="sm"
testID="session-progress"
>
< ProgressFilledTrack / >
< / Progress >
{/* Question */}
< Text
fontSize="$lg"
fontWeight="$semibold"
lineHeight="$xl"
testID="question-text"
>
{question}
< / Text >
{/* Answer input */}
< Input
size="lg"
isDisabled={isLoading || isSubmitting}
testID="answer-input"
>
< InputField
placeholder="Enter your answer..."
value={answer}
onChangeText={setAnswer}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={handleSubmit}
returnKeyType="send"
/>
< / Input >
{/* Game stats */}
< HStack justifyContent = "space-between" alignItems = "center" >
< Text fontSize = "$sm" color = "$textLight600" testID = "attempts-counter" >
Attempts left: {attemptsLeft}/3
< / Text >
< Text fontSize = "$sm" color = "$textLight600" testID = "score-display" >
Score: {currentScore} points
< / Text >
< / HStack >
{/* Action buttons */}
< HStack space = "sm" >
< Button
size="lg"
variant="solid"
action="primary"
flex={1}
onPress={handleSubmit}
isDisabled={!answer.trim() || isLoading || isSubmitting}
testID="submit-button"
>
< ButtonText >
{isSubmitting ? 'Submitting...' : 'Submit Answer'}
< / ButtonText >
< / Button >
< Button
size="lg"
variant="outline"
action="secondary"
onPress={handleHintRequest}
isDisabled={isLoading || isSubmitting}
testID="hint-button"
>
< ButtonText > 💡 Hint< / ButtonText >
< / Button >
< / HStack >
< / VStack >
< / Card >
)
)
}
}
// Default export for dynamic imports
export default GameCard
```
```
#### Custom Hooks Pattern
#### Solid Composable Pattern
```typescript
```typescript
// Custom hook with proper TypeScript types
import { createSignal } from 'solid-js'
import { useState, useEffect, useCallback, useRef } from 'react'
import { GameSessionService } from '../services/game-session-service'
import { GameSessionService } from '../services/game-session-service'
interface UseGameSessionOptions {
export const createGameSessionStore = (playerName: () => string) => {
playerName: string
const service = new GameSessionService()
onSessionEnd?: (finalScore: number) => void
const [isLoading, setIsLoading] = createSignal(false)
onError?: (error: Error) => void
const [error, setError] = createSignal< string | null > (null)
}
const [sessionId, setSessionId] = createSignal< string | null > (null)
interface GameSessionState {
sessionId: string | null
currentQuestion: Question | null
score: number
attemptsLeft: number
timeRemaining: number
isLoading: boolean
error: string | null
}
interface GameSessionActions {
startGame: () => Promise< void >
submitAnswer: (answer: string) => Promise< boolean >
requestHint: () => Promise< string >
endGame: () => Promise< void >
}
export const useGameSession = (
const startGame = async () => {
options: UseGameSessionOptions
setIsLoading(true)
): [GameSessionState, GameSessionActions] => {
setError(null)
const [state, setState] = useState< GameSessionState > ({
sessionId: null,
currentQuestion: null,
score: 0,
attemptsLeft: 3,
timeRemaining: 30 * 60, // 30 minutes
isLoading: false,
error: null,
})
const timerRef = useRef< NodeJS.Timeout | null > (null)
const gameService = useRef(new GameSessionService())
// Timer effect
useEffect(() => {
if (state.sessionId & & state.timeRemaining > 0) {
timerRef.current = setInterval(() => {
setState(prev => {
const newTimeRemaining = prev.timeRemaining - 1
if (newTimeRemaining < = 0 & & options.onSessionEnd) {
options.onSessionEnd(prev.score)
}
return { ...prev, timeRemaining: Math.max(0, newTimeRemaining) }
})
}, 1000)
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
}, [state.sessionId, state.timeRemaining, options.onSessionEnd])
// Actions
const startGame = useCallback(async () => {
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
try {
const session = await gameService.current.startGame(options.playerName)
const session = await service.startGame(playerName())
setState(prev => ({
setSessionId(session.sessionId)
...prev,
return session
sessionId: session.sessionId,
} catch (err) {
currentQuestion: session.firstQuestion,
const message = err instanceof Error ? err.message : 'Failed to start game'
score: 0,
setError(message)
attemptsLeft: 3,
throw err
timeRemaining: session.timeRemaining,
} finally {
isLoading: false,
setIsLoading(false)
}))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to start game'
setState(prev => ({ ...prev, error: errorMessage, isLoading: false }))
options.onError?.(error instanceof Error ? error : new Error(errorMessage))
}
}, [options.playerName, options.onError])
const submitAnswer = useCallback(async (answer: string): Promise< boolean > => {
if (!state.sessionId || !state.currentQuestion) {
throw new Error('No active game session')
}
setState(prev => ({ ...prev, isLoading: true }))
try {
const result = await gameService.current.submitAnswer(
state.sessionId,
state.currentQuestion.id,
answer
)
setState(prev => ({
...prev,
score: result.newScore,
attemptsLeft: result.attemptsLeft,
currentQuestion: result.nextQuestion,
isLoading: false,
}))
return result.isCorrect
} catch (error) {
setState(prev => ({ ...prev, isLoading: false }))
throw error
}
}, [state.sessionId, state.currentQuestion])
const requestHint = useCallback(async (): Promise< string > => {
if (!state.sessionId || !state.currentQuestion) {
throw new Error('No active game session')
}
const hint = await gameService.current.requestHint(
state.sessionId,
state.currentQuestion.id
)
return hint
}, [state.sessionId, state.currentQuestion])
const endGame = useCallback(async () => {
if (!state.sessionId) return
await gameService.current.endGame(state.sessionId)
setState({
sessionId: null,
currentQuestion: null,
score: 0,
attemptsLeft: 3,
timeRemaining: 30 * 60,
isLoading: false,
error: null,
})
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
}, [state.sessionId])
}
return [
return {
state,
isLoading,
{
error,
startGame,
sessionId,
submitAnswer,
startGame,
requestHint,
}
endGame,
},
]
}
}
```
```
@ -1148,196 +897,50 @@ func BenchmarkGameService_StartGame(b *testing.B) {
### TypeScript Testing Patterns
### TypeScript Testing Patterns
```typescript
```typescript
// React component testing with Jest and Testing Library
// SolidJS component testing with Vitest and Testing Library
import React from 'react'
import { render, screen, fireEvent } from '@solidjs/testing-library'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { GluestackUIProvider } from '@gluestack-ui/themed'
import { GameCard, GameCardProps } from '../GameCard'
import { GameCard, GameCardProps } from '../GameCard'
// Test wrapper for Gluestack UI
const TestWrapper: React.FC< { children: React.ReactNode }> = ({ children }) => (
< GluestackUIProvider > {children}< / GluestackUIProvider >
)
// Mock props factory
// Mock props factory
const createMockProps = (overrides: Partial< GameCardProps > = {}): GameCardProps => ({
const createMockProps = (overrides: Partial< GameCardProps > = {}): GameCardProps => ({
question: "What is the capital of France?",
question: "What is the capital of France?",
theme: "Geography",
theme: "Geography",
timeRemaining: 1800, // 30 minutes
timeRemaining: 1800, // 30 minutes
attemptsLeft: 3,
attemptsLeft: 3,
currentScore: 0,
onSubmitAnswer: vi.fn(),
onSubmitAnswer: jest.fn(),
loading: false,
onRequestHint: jest.fn(),
...overrides,
...overrides,
})
})
describe('GameCard', () => {
describe('GameCard', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
describe('Rendering', () => {
it('should render question and theme correctly', () => {
it('should render question and theme correctly', () => {
const props = createMockProps()
const props = createMockProps()
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
render(() => < GameCard { . . . props } / > )
expect(screen.getByTestId('question-text')).toHaveTextContent(props.question)
expect(screen.getByText(props.question)).toBeInTheDocument()
expect(screen.getByTestId('theme-badge')).toHaveTextContent(props.theme)
expect(screen.getByText(props.theme)).toBeInTheDocument()
})
it('should display timer in correct format', () => {
const props = createMockProps({ timeRemaining: 125 }) // 2:05
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
expect(screen.getByTestId('timer-display')).toHaveTextContent('⏱️ 2:05')
})
it('should show attempts and score correctly', () => {
const props = createMockProps({ attemptsLeft: 2, currentScore: 5 })
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
expect(screen.getByTestId('attempts-counter')).toHaveTextContent('Attempts left: 2/3')
expect(screen.getByTestId('score-display')).toHaveTextContent('Score: 5 points')
})
})
})
})
describe('User Interactions', () => {
describe('User Interactions', () => {
it('should call onSubmitAnswer when submit button is clicked', async () => {
it('should call onSubmitAnswer when submit button is clicked', async () => {
const props = createMockProps()
const props = createMockProps()
const mockOnSubmitAnswer = jest .fn().mockResolvedValue(undefined)
const mockOnSubmitAnswer = vi.fn().mockResolvedValue(undefined)
props.onSubmitAnswer = mockOnSubmitAnswer
props.onSubmitAnswer = mockOnSubmitAnswer
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
// Type an answer
const input = screen.getByTestId('answer-input')
fireEvent.changeText(input, 'Paris')
// Click submit
const submitButton = screen.getByTestId('submit-button')
fireEvent.press(submitButton)
await waitFor(() => {
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
})
})
it('should not submit empty answer', () => {
const props = createMockProps()
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
const submitButton = screen.getByTestId('submit-button')
expect(submitButton).toBeDisabled()
})
it('should call onRequestHint when hint button is clicked', async () => {
const props = createMockProps()
const mockOnRequestHint = jest.fn().mockResolvedValue('City of Light')
props.onRequestHint = mockOnRequestHint
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
const hintButton = screen.getByTestId('hint-button')
fireEvent.press(hintButton)
await waitFor(() => {
expect(mockOnRequestHint).toHaveBeenCalled()
})
})
})
describe('Loading States', () => {
it('should disable inputs when loading', () => {
const props = createMockProps({ isLoading: true })
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
expect(screen.getByTestId('answer-input')).toBeDisabled()
expect(screen.getByTestId('submit-button')).toBeDisabled()
expect(screen.getByTestId('hint-button')).toBeDisabled()
})
})
describe('Timer States', () => {
it('should show warning color when time is low', () => {
const props = createMockProps({ timeRemaining: 300 }) // 5 minutes
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
const timer = screen.getByTestId('timer-display')
expect(timer).toHaveStyle({ color: expect.stringContaining('warning') })
})
it('should show critical color when time is very low', () => {
const props = createMockProps({ timeRemaining: 30 }) // 30 seconds
render(< GameCard { . . . props } / > , { wrapper: TestWrapper })
const timer = screen.getByTestId('timer-display')
expect(timer).toHaveStyle({ color: expect.stringContaining('error') })
})
})
})
// Hook testing
render(() => < GameCard { . . . props } / > )
import { renderHook, act } from '@testing-library/react'
import { useGameSession } from '../hooks/useGameSession'
// Mock the service
const input = screen.getByPlaceholderText('Enter your answer')
jest.mock('../services/game-session-service' )
fireEvent.input(input, { target: { value: 'Paris' } })
describe('useGameSession', () => {
const submitButton = screen.getByRole('button', { name: 'Submit Answer' })
const mockGameService = {
fireEvent.click(submitButton)
startGame: jest.fn(),
submitAnswer: jest.fn(),
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
requestHint: jest.fn(),
endGame: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
// Set up service mock
;(GameSessionService as jest.Mock).mockImplementation(() => mockGameService)
})
it('should start game successfully', async () => {
mockGameService.startGame.mockResolvedValue({
sessionId: 'session123',
firstQuestion: { id: 'q1', text: 'Test question' },
timeRemaining: 1800,
})
const { result } = renderHook(() =>
useGameSession({ playerName: 'TestPlayer' })
)
await act(async () => {
await result.current[1].startGame()
})
expect(result.current[0].sessionId).toBe('session123')
expect(result.current[0].currentQuestion).toBeDefined()
})
it('should handle errors during game start', async () => {
const mockError = new Error('Failed to start game')
mockGameService.startGame.mockRejectedValue(mockError)
const onError = jest.fn()
const { result } = renderHook(() =>
useGameSession({ playerName: 'TestPlayer', onError })
)
await act(async () => {
await result.current[1].startGame()
})
})
expect(result.current[0].error).toBe('Failed to start game')
expect(onError).toHaveBeenCalledWith(mockError)
})
})
})
})
```
```
@ -1404,26 +1007,19 @@ docker-compose up -d
### Frontend Development
### Frontend Development
Note: This project uses Yarn with `nodeLinker: node-modules` . PnP files like `.pnp.cjs` and `.pnp.loader.mjs` are not used.
Note: This project uses Yarn with `nodeLinker: node-modules` . PnP files like `.pnp.cjs` and `.pnp.loader.mjs` are not used.
Status: `Web` commands are implemented. `Tauri cross-platform` commands are planned and require a future `frontend/apps/cross-platform` workspace.
```bash
```bash
# Web application
# Web application
cd frontend/apps/web
cd frontend
npm install
yarn install
npm run dev # Start development server
yarn dev # Start web development server (SolidJS)
npm run build # Production build
yarn build # Build all frontend workspaces
npm run test # Run Jest tests
yarn test # Run Vitest suites
npm run lint # ESLint
yarn lint # ESLint
npm run type-check # TypeScript checking
yarn format:check # Prettier check
# Mobile application
# Cross-platform packaging with Tauri (desktop/mobile)
cd frontend/apps/mobile
cd frontend/apps/cross-platform
npm install
yarn tauri dev # Run Tauri app with embedded web UI
npx expo start # Start Expo development
yarn tauri build # Build release bundles
npx expo run:ios # Run on iOS simulator
npx expo run:android # Run on Android emulator
# Desktop application
cd frontend/apps/desktop
npm install
npm run dev # Start Wails development
npm run build # Build desktop app
```
```