Updated to documentation to align with most recent technical choices (SolidJS, Tauri)

master
oabrivard 1 month ago
parent 7f2e43aaf0
commit 34d597b511

@ -88,7 +88,7 @@ Know Foolery is a quiz game inspired by the French game "Déconnaissance" (https
- Production-ready deployment - Production-ready deployment
### Phase 3: Mobile Expansion (Weeks 13-18) ### Phase 3: Mobile Expansion (Weeks 13-18)
- React Native mobile applications - Cross-platform mobile applications
- Cross-platform component optimization - Cross-platform component optimization
- Mobile app store deployment - Mobile app store deployment

@ -70,7 +70,7 @@ Web Application:
Language: TypeScript 5.0+ Language: TypeScript 5.0+
Build Tool: Vite 4.0+ Build Tool: Vite 4.0+
UI Library: SUID (suid.io) UI Library: SUID (suid.io)
Testing: Jest + Playwright Testing: Vitest + Playwright
Mobile Applications: Mobile Applications:
Framework: Tauri 2.9.5+ Framework: Tauri 2.9.5+
@ -171,8 +171,8 @@ Circuit Breaker:
### Cross-Platform Deployment ### Cross-Platform Deployment
- **Web**: Standard web application deployment - **Web**: Standard web application deployment
- **Mobile**: iOS App Store and Google Play Store distribution - **Mobile**: iOS App Store and Google Play Store distribution via Tauri
- **Desktop**: Wails applications for major operating systems - **Desktop**: Tauri applications for major operating systems
### Infrastructure Technologies ### Infrastructure Technologies
```yaml ```yaml
@ -293,7 +293,7 @@ knowfoolery/
│ │ │ └── styles/ │ │ │ └── styles/
│ │ │ └── global.css │ │ │ └── global.css
│ │ └── tsconfig.json │ │ └── tsconfig.json
│ └── cross-platform/ # Tauri app (embeds the SolidJS web app) │ └── cross-platform/ # Tauri app
│ ├── package.json │ ├── package.json
│ ├── vite.config.ts │ ├── vite.config.ts
│ ├── src/ # Inherits most from web app │ ├── src/ # Inherits most from web app

@ -77,7 +77,7 @@ Zitadel serves as the self-hosted OAuth 2.0/OpenID Connect authentication provid
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ Client Applications │ │ Client Applications │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web App │ │ Mobile iOS │ │Mobile Android│ │Desktop Wails│ │ │ │ Web App │ │ Mobile iOS │ │Mobile Android│ │Desktop Tauri│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘

@ -10,11 +10,11 @@
- Custom middleware for JWT authentication and rate limiting - Custom middleware for JWT authentication and rate limiting
- Comprehensive testing with testcontainers for integration tests - Comprehensive testing with testcontainers for integration tests
### TypeScript/React Patterns ### TypeScript/SolidJS Patterns
- Components use Gluestack UI with proper TypeScript typing - Components use Gluestack UI with proper TypeScript typing
- Custom hooks pattern for business logic (e.g., `useGameSession`) - Custom hooks pattern for business logic (e.g., `useGameSession`)
- Context + useReducer for state management - Context + useReducer for state management
- Comprehensive testing with Jest + React Testing Library - Comprehensive testing with Jest + Recommended UI Testing Library
### Authentication Flow ### Authentication Flow
- Zitadel provides OAuth 2.0/OIDC authentication - Zitadel provides OAuth 2.0/OIDC authentication
@ -187,8 +187,7 @@ service LeaderboardService {
### Frontend Testing ### Frontend Testing
- **Unit tests** with Jest - **Unit tests** with Jest
- **Component tests** with Jest + React Testing Library - **Component tests** with Jest + vitest + Recoummended Testing Library
- **Hook tests** with @testing-library/react-hooks
### End to End Testing ### End to End Testing
- **E2E Testing** with Playwright for complete user journeys - **E2E Testing** with Playwright for complete user journeys

@ -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
``` ```

@ -565,24 +565,24 @@ export class GameMetricsTracker {
// Detect platform // Detect platform
if (/Android/i.test(navigator.userAgent)) return 'android' if (/Android/i.test(navigator.userAgent)) return 'android'
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) return 'ios' if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) return 'ios'
if (window.wails) return 'desktop' // For Wails apps if ('__TAURI__' in window) return 'desktop' // For Tauri apps
return 'web' return 'web'
} }
} }
// Usage in React components // Usage in SolidJS modules/components
export const useGameMetrics = () => { export const createGameMetrics = () => {
const collector = useRef(new MetricsCollector('/api/v1/metrics')) const collector = new MetricsCollector('/api/v1/metrics')
const gameTracker = useRef(new GameMetricsTracker(collector.current)) const gameTracker = new GameMetricsTracker(collector)
return { return {
trackGameStart: gameTracker.current.trackGameStart.bind(gameTracker.current), trackGameStart: gameTracker.trackGameStart.bind(gameTracker),
trackQuestionDisplayed: gameTracker.current.trackQuestionDisplayed.bind(gameTracker.current), trackQuestionDisplayed: gameTracker.trackQuestionDisplayed.bind(gameTracker),
trackAnswerSubmitted: gameTracker.current.trackAnswerSubmitted.bind(gameTracker.current), trackAnswerSubmitted: gameTracker.trackAnswerSubmitted.bind(gameTracker),
trackHintRequested: gameTracker.current.trackHintRequested.bind(gameTracker.current), trackHintRequested: gameTracker.trackHintRequested.bind(gameTracker),
trackGameCompleted: gameTracker.current.trackGameCompleted.bind(gameTracker.current), trackGameCompleted: gameTracker.trackGameCompleted.bind(gameTracker),
trackUserAction: collector.current.trackUserAction.bind(collector.current), trackUserAction: collector.trackUserAction.bind(collector),
trackError: collector.current.trackError.bind(collector.current), trackError: collector.trackError.bind(collector),
} }
} }
``` ```
@ -797,26 +797,27 @@ export class FrontendTracing {
} }
} }
// React hook for tracing // SolidJS composable for tracing
export const useTracing = () => { export const createTracing = () => {
const tracer = trace.getTracer('react-components') const tracer = trace.getTracer('solid-components')
const traceUserAction = useCallback( const traceUserAction = async (
async (action: string, properties: Record<string, any>, fn: () => Promise<void>) => { action: string,
return tracer.startActiveSpan(`user.${action}`, async (span) => { properties: Record<string, any>,
try { fn: () => Promise<void>
span.setAttributes(properties) ) => {
await fn() return tracer.startActiveSpan(`user.${action}`, async (span) => {
} catch (error) { try {
span.recordException(error as Error) span.setAttributes(properties)
throw error await fn()
} finally { } catch (error) {
span.end() span.recordException(error as Error)
} throw error
}) } finally {
}, span.end()
[tracer] }
) })
}
return { traceUserAction } return { traceUserAction }
} }
@ -1443,4 +1444,4 @@ receivers:
text: '{{ range .Alerts }}{{ .Annotations.summary }}{{ end }}' text: '{{ range .Alerts }}{{ .Annotations.summary }}{{ end }}'
``` ```
This comprehensive observability strategy ensures that Know Foolery has full visibility into its performance, user behavior, and system health, enabling proactive issue resolution and data-driven product improvements. This comprehensive observability strategy ensures that Know Foolery has full visibility into its performance, user behavior, and system health, enabling proactive issue resolution and data-driven product improvements.

Loading…
Cancel
Save