Finished step '4.2 Shared UI Components'

master
oabrivard 1 month ago
parent 609741bcde
commit 1b438b0ad1

@ -17,7 +17,6 @@
- **Microservices Architecture**: Independent scaling of components
- **Database**: PostgreSQL with read replicas for high availability
- **Caching**: Redis for session state and frequently accessed data
- **Auto-scaling**: Kubernetes-based horizontal scaling
## Reliability
- **Uptime**: 99.9% availability target
@ -36,5 +35,7 @@
### Audit & Compliance
- **Audit Trails**: Comprehensive logging of all administrative actions
- **Compliance Reporting**: SOC 2, ISO 27001 compliance capabilities
- **Data Retention**: Defined policies for data lifecycle management
- **Data Retent
## Future Enhancements (do not take into account for now)
- **Auto-scaling**: Kubernetes-based horizontal scaling

@ -146,9 +146,9 @@ Communication Patterns:
Asynchronous: asynchronous gRPC calls (Event-driven via message queues in the future)
Service Discovery:
Registry: Kubernetes DNS
Health Checks: HTTP /health endpoints
Load Balancing: Round-robin with health awareness
Registry: No separate registry. Use Docker Compose networking as registry. Put services on the same Compose network. Call services by service name (DNS). Keep endpoints in env vars.
Health Checks: HTTP /health endpoints. Add healthcheck + depends_on: condition: service_healthy to reduce startup race issues.
Load Balancing: No load balancing
Circuit Breaker:
Pattern: Hystrix-style circuit breakers
@ -164,7 +164,7 @@ Circuit Breaker:
- **Testing**: Comprehensive unit, integration, and E2E testing
### Production Environment
- **Container Orchestration**: Kubernetes for production deployment
- **Container Orchestration**: Docker Compose for production deployment
- **Database**: PostgreSQL with high availability configuration
- **Monitoring**: Prometheus, Grafana, Jaeger for observability
- **CI/CD**: GitHub Actions for automated testing and deployment
@ -178,7 +178,7 @@ Circuit Breaker:
```yaml
Containerization:
Runtime: Docker 24+
Orchestration: Kubernetes 1.28+
Orchestration: Docker Compose
Registry: Private Docker Registry
Observability:

@ -304,7 +304,7 @@ GET /admin/audit # Audit log viewer
---
## Phase 4: Frontend (Weeks 7-9)
## Phase 4: Web Frontend (Weeks 7-9)
### 4.1 Web Application (SolidJS)
@ -396,34 +396,6 @@ frontend/shared/ui-components/
└── ThemeBadge/ # Question theme display
```
### 4.3 Desktop/Mobile (Tauri)
**Priority:** LOW
**Duration:** 3-4 days
**Depends on:** 4.1
**Project Structure:**
```
frontend/apps/cross-platform/
├── src/ # Inherits from web app
├── src-tauri/
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ ├── capabilities/
│ ├── icons/
│ └── src/
│ ├── main.rs
│ ├── lib.rs
│ └── commands/ # Native commands
```
**Target Platforms:**
- macOS (Intel + Apple Silicon)
- Windows (x64)
- Linux (x64)
- iOS (via Tauri 2.x)
- Android (via Tauri 2.x)
---
## Phase 5: Infrastructure (Weeks 8-9)
@ -474,39 +446,6 @@ services:
ports: ["16686:16686"]
```
### 5.2 Kubernetes Manifests
**Priority:** MEDIUM
**Duration:** 3-4 days
**Directory Structure:**
```
infrastructure/k8s/
├── base/
│ ├── namespace.yaml
│ ├── configmaps/
│ │ ├── game-config.yaml
│ │ └── observability-config.yaml
│ ├── secrets/
│ │ └── database-secrets.yaml
│ └── services/
│ ├── gateway/
│ ├── game-session/
│ ├── question-bank/
│ ├── user/
│ ├── leaderboard/
│ └── admin/
├── overlays/
│ ├── dev/
│ │ └── kustomization.yaml
│ └── prod/
│ └── kustomization.yaml
└── monitoring/
├── prometheus/
├── grafana/
└── jaeger/
```
### 5.3 CI/CD Pipeline
**Priority:** HIGH
@ -532,10 +471,9 @@ infrastructure/k8s/
**CD Pipeline Stages:**
1. Pull latest images
2. Run database migrations
3. Deploy to Kubernetes
4. Health checks
5. Smoke tests
6. Rollback on failure
3. Health checks
4. Smoke tests
5. Rollback on failure
---
@ -615,6 +553,36 @@ func TestGameService_StartGame(t *testing.T) {
---
### Phase 7: Desktop/Mobile (Tauri)
**Priority:** LOW
**Duration:** 3-4 days
**Depends on:** 4.1
**Project Structure:**
```
frontend/apps/cross-platform/
├── src/ # Inherits from web app
├── src-tauri/
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ ├── capabilities/
│ ├── icons/
│ └── src/
│ ├── main.rs
│ ├── lib.rs
│ └── commands/ # Native commands
```
**Target Platforms:**
- macOS (Intel + Apple Silicon)
- Windows (x64)
- Linux (x64)
- iOS (via Tauri 2.x)
- Android (via Tauri 2.x)
---
## Implementation Order Summary
```
@ -648,7 +616,6 @@ Week 7-8: Frontend
Week 9: Polish & Deploy
├── Tauri desktop/mobile packaging
├── Kubernetes deployment
├── Production configuration
├── Documentation updates
└── Performance testing
@ -751,7 +718,7 @@ Week 9: Polish & Deploy
---
## Appendix: Technology Stack
## Appendix A: Technology Stack
### Backend
| Component | Technology | Version |
@ -780,8 +747,45 @@ Week 9: Polish & Deploy
| Component | Technology | Version |
|-----------|------------|---------|
| Containerization | Docker | 24+ |
| Orchestration | Kubernetes | 1.28+ |
| Orchestration | Docker Compose | Latest |
| CI/CD | GitHub Actions | Latest |
| Monitoring | Prometheus + Grafana | Latest |
| Tracing | Jaeger | Latest |
| Logging | Loki | Latest |
---
## Appendix B: Future Enhancements (do not take into account for now)
### Kubernetes Manifests
**Priority:** MEDIUM
**Duration:** 3-4 days
**Directory Structure:**
```
infrastructure/k8s/
├── base/
│ ├── namespace.yaml
│ ├── configmaps/
│ │ ├── game-config.yaml
│ │ └── observability-config.yaml
│ ├── secrets/
│ │ └── database-secrets.yaml
│ └── services/
│ ├── gateway/
│ ├── game-session/
│ ├── question-bank/
│ ├── user/
│ ├── leaderboard/
│ └── admin/
├── overlays/
│ ├── dev/
│ │ └── kustomization.yaml
│ └── prod/
│ └── kustomization.yaml
└── monitoring/
├── prometheus/
├── grafana/
└── jaeger/
```

@ -1 +1 @@
{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/ui/Timer.test.ts",{"duration":2,"failed":false}]]}
{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":3,"failed":false}],[":src/routes/Results.test.tsx",{"duration":62,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":181,"failed":false}],[":src/routes/Game.test.tsx",{"duration":184,"failed":false}]]}

@ -11,6 +11,7 @@
"clean": "rm -rf dist node_modules/.vite"
},
"dependencies": {
"@knowfoolery/ui-components": "workspace:*",
"@solidjs/router": "^0.10.0",
"@suid/icons-material": "^0.7.0",
"@suid/material": "^0.16.0",

@ -0,0 +1,19 @@
import { render, screen } from '@solidjs/testing-library'
import { describe, expect, it, vi } from 'vitest'
import GameRoute from './Game'
vi.mock('@solidjs/router', () => ({
useNavigate: () => vi.fn(),
}))
describe('GameRoute', () => {
it('renders shared game UI components', () => {
render(() => <GameRoute />)
expect(screen.getByText('Partie')).toBeTruthy()
expect(screen.getByLabelText('Ta réponse')).toBeTruthy()
expect(screen.getByText('Essais')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Indice (score réduit)' })).toBeTruthy()
})
})

@ -1,25 +1,21 @@
import { useNavigate } from '@solidjs/router'
import { createEffect, createMemo, createSignal, onMount } from 'solid-js'
import Box from '@suid/material/Box'
import {
AnswerInput,
AttemptIndicator,
GameCard,
HintButton,
ScoreDisplay,
Timer,
} from '@knowfoolery/ui-components'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import Dialog from '@suid/material/Dialog'
import DialogActions from '@suid/material/DialogActions'
import DialogContent from '@suid/material/DialogContent'
import DialogTitle from '@suid/material/DialogTitle'
import Divider from '@suid/material/Divider'
import Stack from '@suid/material/Stack'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
import { useTimer } from '../hooks/useTimer'
import { leaderboardClient } from '../services/api'
import { appendGameHistory, saveLastResult } from '../services/session'
import AttemptIndicator from '../ui/AttemptIndicator'
import ScoreDisplay from '../ui/ScoreDisplay'
import Timer from '../ui/Timer'
type QuizQuestion = {
theme: string
@ -35,10 +31,6 @@ const QUESTION: QuizQuestion = {
hint: 'Cest le seul nombre premier pair.',
}
function readInputValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
function normalizeAnswer(answer: string): string {
return answer.trim().toLowerCase()
}
@ -76,7 +68,6 @@ export default function GameRoute() {
const [hintUsed, setHintUsed] = createSignal(false)
const [score, setScore] = createSignal(0)
const [message, setMessage] = createSignal<string | null>(null)
const [hintDialogOpen, setHintDialogOpen] = createSignal(false)
const [finished, setFinished] = createSignal(false)
const durationMs = 30 * 60 * 1000
@ -155,86 +146,53 @@ export default function GameRoute() {
setAnswer('')
}
const openHintDialog = () => {
if (hintUsed() || attempts() > 0 || answerLocked()) return
setHintDialogOpen(true)
}
const confirmHint = () => {
if (hintUsed() || attempts() > 0 || answerLocked()) return
setHintUsed(true)
setHintDialogOpen(false)
setMessage(`Indice: ${question().hint}`)
}
return (
<Box>
<Stack spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Partie
</Typography>
<Typography sx={{ opacity: 0.8 }}>Thème : {question().theme}</Typography>
</Box>
<Timer remainingMs={remainingMs()} />
<ScoreDisplay score={score()} />
</Stack>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{question().text}
</Typography>
<TextField
label="Ta réponse"
value={answer()}
onInput={(e) => setAnswer(readInputValue(e))}
fullWidth
disabled={answerLocked()}
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
onKeyDown={(e) => {
if (e.key === 'Enter') submit()
}}
/>
<AttemptIndicator attemptsUsed={attempts()} attemptsMax={3} />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" disabled={answerLocked()} onClick={submit}>
Envoyer
</Button>
<Button variant="outlined" disabled={hintUsed() || attempts() > 0 || answerLocked()} onClick={openHintDialog}>
Indice (score réduit)
</Button>
</Stack>
<Divider />
{message() && <Typography sx={{ opacity: 0.9 }}>{message()}</Typography>}
{attemptsLeft() === 0 && <Typography color="warning.main">Plus d'essais.</Typography>}
{isExpired() && <Typography color="error.main">Temps écoulé.</Typography>}
</Stack>
</CardContent>
</Card>
</Stack>
<Dialog open={hintDialogOpen()} onClose={() => setHintDialogOpen(false)}>
<DialogTitle>Confirmer lindice</DialogTitle>
<DialogContent>
<Typography>
Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setHintDialogOpen(false)}>Annuler</Button>
<Button variant="contained" onClick={confirmHint}>
Oui, utiliser un indice
<GameCard
title="Partie"
theme={question().theme}
themeLabelPrefix="Thème : "
questionText={question().text}
timerSlot={<Timer remainingMs={remainingMs()} />}
scoreSlot={<ScoreDisplay score={score()} />}
answerSlot={
<AnswerInput
label="Ta réponse"
value={answer()}
disabled={answerLocked()}
onInputValue={setAnswer}
onSubmit={submit}
/>
}
attemptSlot={<AttemptIndicator attemptsUsed={attempts()} attemptsMax={3} />}
actionsSlot={
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" disabled={answerLocked()} onClick={submit}>
Envoyer
</Button>
</DialogActions>
</Dialog>
</Box>
<HintButton
disabled={hintUsed() || attempts() > 0 || answerLocked()}
buttonLabel="Indice (score réduit)"
confirmTitle="Confirmer lindice"
confirmMessage="Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?"
confirmLabel="Oui, utiliser un indice"
cancelLabel="Annuler"
onConfirm={confirmHint}
/>
</Stack>
}
feedbackSlot={
<>
{message() && <Typography sx={{ opacity: 0.9 }}>{message()}</Typography>}
{attemptsLeft() === 0 && <Typography color="warning.main">Plus d'essais.</Typography>}
{isExpired() && <Typography color="error.main">Temps écoulé.</Typography>}
</>
}
/>
)
}

@ -0,0 +1,16 @@
import { render, screen, waitFor } from '@solidjs/testing-library'
import { describe, expect, it } from 'vitest'
import LeaderboardRoute from './Leaderboard'
describe('LeaderboardRoute', () => {
it('renders leaderboard rows via shared table', async () => {
render(() => <LeaderboardRoute />)
await waitFor(() => {
expect(screen.getByText('Alice')).toBeTruthy()
})
expect(screen.getByRole('table', { name: 'top-10-leaderboard' })).toBeTruthy()
})
})

@ -1,25 +1,15 @@
import { For, Show, createResource } from 'solid-js'
import { createResource } from 'solid-js'
import { LeaderboardTable } from '@knowfoolery/ui-components'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import CircularProgress from '@suid/material/CircularProgress'
import Stack from '@suid/material/Stack'
import Table from '@suid/material/Table'
import TableBody from '@suid/material/TableBody'
import TableCell from '@suid/material/TableCell'
import TableHead from '@suid/material/TableHead'
import TableRow from '@suid/material/TableRow'
import Typography from '@suid/material/Typography'
import { leaderboardClient } from '../services/api'
function formatDuration(durationSec: number): string {
const minutes = Math.floor(durationSec / 60)
return `${minutes}m`
}
export default function LeaderboardRoute() {
const [items, { refetch }] = createResource(async () => leaderboardClient.top10())
@ -37,39 +27,7 @@ export default function LeaderboardRoute() {
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Show when={!items.loading} fallback={<CircularProgress />}>
<Show
when={(items() ?? []).length > 0}
fallback={<Typography sx={{ opacity: 0.8 }}>Aucun score pour le moment.</Typography>}
>
<Table size="small" aria-label="top-10-leaderboard">
<TableHead>
<TableRow>
<TableCell>Rang</TableCell>
<TableCell>Joueur</TableCell>
<TableCell align="right">Score</TableCell>
<TableCell align="right">Questions</TableCell>
<TableCell align="right">Taux de réussite</TableCell>
<TableCell align="right">Durée</TableCell>
</TableRow>
</TableHead>
<TableBody>
<For each={(items() ?? []).slice(0, 10)}>
{(row, idx) => (
<TableRow>
<TableCell>#{idx() + 1}</TableCell>
<TableCell sx={{ fontWeight: 700 }}>{row.player}</TableCell>
<TableCell align="right">{row.score}</TableCell>
<TableCell align="right">{row.questions}</TableCell>
<TableCell align="right">{row.successRate}%</TableCell>
<TableCell align="right">{formatDuration(row.durationSec)}</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Show>
</Show>
<LeaderboardTable loading={items.loading} rows={items() ?? []} />
</CardContent>
</Card>

@ -0,0 +1,34 @@
import { render, screen } from '@solidjs/testing-library'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ResultsRoute from './Results'
vi.mock('@solidjs/router', () => ({
useNavigate: () => vi.fn(),
}))
describe('ResultsRoute', () => {
beforeEach(() => {
localStorage.setItem(
'kf.lastResult',
JSON.stringify({
playerName: 'Alice',
finalScore: 10,
answered: 6,
correct: 5,
successRate: 83,
durationSec: 1200,
leaderboardPosition: 3,
finishedAt: new Date().toISOString(),
})
)
})
it('renders stored result using shared results card', () => {
render(() => <ResultsRoute />)
expect(screen.getByText('Joueur : Alice')).toBeTruthy()
expect(screen.getByText('Score final : 10')).toBeTruthy()
expect(screen.getByText('Position leaderboard : #3')).toBeTruthy()
})
})

@ -1,74 +1,24 @@
import { useNavigate } from '@solidjs/router'
import { Show, createMemo } from 'solid-js'
import { createMemo } from 'solid-js'
import { ResultsCard } from '@knowfoolery/ui-components'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
import { loadLastResult } from '../services/session'
function formatDuration(durationSec: number): string {
const minutes = Math.floor(durationSec / 60)
const seconds = durationSec % 60
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
}
export default function ResultsRoute() {
const navigate = useNavigate()
const result = createMemo(() => loadLastResult())
return (
<Box>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Show
when={result()}
fallback={
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Résultats
</Typography>
<Typography sx={{ opacity: 0.8 }}>Aucune partie terminée pour le moment.</Typography>
<Button variant="contained" onClick={() => navigate('/game')}>
Démarrer une partie
</Button>
</Stack>
}
>
{(last) => (
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Résultats
</Typography>
<Typography sx={{ opacity: 0.8 }}>Joueur : {last().playerName}</Typography>
<Typography>Score final : {last().finalScore}</Typography>
<Typography>
Questions répondues / correctes : {last().answered} / {last().correct}
</Typography>
<Typography>Taux de réussite : {last().successRate}%</Typography>
<Typography>Durée de session : {formatDuration(last().durationSec)}</Typography>
<Typography>
Position leaderboard :{' '}
{last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" onClick={() => navigate('/game')}>
Rejouer
</Button>
<Button variant="outlined" onClick={() => navigate('/leaderboard')}>
Voir le leaderboard
</Button>
</Stack>
</Stack>
)}
</Show>
</CardContent>
</Card>
<ResultsCard
result={result()}
onPlayAgain={() => navigate('/game')}
onViewLeaderboard={() => navigate('/leaderboard')}
onStartGame={() => navigate('/game')}
title="Résultats"
/>
</Box>
)
}

@ -1,32 +0,0 @@
import type { Component } from 'solid-js'
import Box from '@suid/material/Box'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
const AttemptIndicator: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
return (
<Stack direction="row" spacing={2} alignItems="center">
<Typography sx={{ opacity: 0.8 }}>Essais</Typography>
<Stack direction="row" spacing={0.5}>
{Array.from({ length: props.attemptsMax }).map((_, i) => (
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: i < used() ? '#ef4444' : '#334155',
}}
/>
))}
</Stack>
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>
{Math.max(0, props.attemptsMax - used())} restant(s)
</Typography>
</Stack>
)
}
export default AttemptIndicator

@ -1,9 +0,0 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
const ScoreDisplay: Component<{ score: number }> = (props) => {
return <Chip label={`Score: ${props.score}`} color="primary" variant="outlined" />
}
export default ScoreDisplay

@ -1,43 +0,0 @@
import { Show, type Component } from 'solid-js'
import Chip from '@suid/material/Chip'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
export function formatMs(ms: number) {
const total = Math.ceil(ms / 1000)
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function getTimerWarning(remainingMs: number): string | null {
if (remainingMs <= 10_000) return '10 secondes restantes'
if (remainingMs <= 60_000) return '1 minute restante'
if (remainingMs <= 300_000) return '5 minutes restantes'
return null
}
const Timer: Component<{ remainingMs: number }> = (props) => {
const color = () => {
if (props.remainingMs <= 10_000) return 'error'
if (props.remainingMs <= 60_000) return 'warning'
if (props.remainingMs <= 300_000) return 'info'
return 'default'
}
const warning = () => getTimerWarning(props.remainingMs)
return (
<Stack spacing={0.5} alignItems="flex-end">
<Chip label={formatMs(props.remainingMs)} color={color() as any} variant="outlined" />
<Show when={warning()}>
<Typography variant="caption" sx={{ color: 'warning.main', fontWeight: 700 }}>
{warning()}
</Typography>
</Show>
</Stack>
)
}
export default Timer

@ -1,6 +1,8 @@
import { defineConfig } from 'vitest/config'
import solid from 'vite-plugin-solid'
export default defineConfig({
plugins: [solid()],
test: {
environment: 'jsdom',
},

@ -0,0 +1 @@
{"version":"1.6.1","results":[[":src/utils/timer.test.ts",{"duration":7,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":24,"failed":false}],[":src/components/ResultsCard.test.tsx",{"duration":96,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":38,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":58,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":115,"failed":false}]]}

@ -5,7 +5,7 @@
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "vitest",
"test": "vitest --config vitest.config.ts",
"build": "echo 'No build needed for shared package'",
"clean": "echo 'Nothing to clean'"
},
@ -14,6 +14,10 @@
"solid-js": "^1.9.0"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.0",
"jsdom": "^26.1.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.8.0",
"vitest": "^1.0.0"
},
"peerDependencies": {

@ -1,32 +1,9 @@
import type { Component } from 'solid-js'
import Box from '@suid/material/Box'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
import AttemptIndicator from './components/AttemptIndicator'
const AttemptDots: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
return (
<Stack direction="row" spacing={2} alignItems="center">
<Typography sx={{ opacity: 0.8 }}>Essais</Typography>
<Stack direction="row" spacing={0.5}>
{Array.from({ length: props.attemptsMax }).map((_, i) => (
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: i < used() ? '#ef4444' : '#334155',
}}
/>
))}
</Stack>
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>
{Math.max(0, props.attemptsMax - used())} restant(s)
</Typography>
</Stack>
)
return <AttemptIndicator attemptsUsed={props.attemptsUsed} attemptsMax={props.attemptsMax} />
}
export default AttemptDots

@ -1,9 +1,9 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
import ScoreDisplay from './components/ScoreDisplay'
const ScoreChip: Component<{ score: number }> = (props) => {
return <Chip label={`Score: ${props.score}`} color="primary" variant="outlined" />
return <ScoreDisplay score={props.score} />
}
export default ScoreChip

@ -1,22 +1,9 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
function formatMs(ms: number) {
const total = Math.ceil(ms / 1000)
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
import Timer from './components/Timer'
const TimerChip: Component<{ remainingMs: number }> = (props) => {
const color = () => {
if (props.remainingMs <= 10_000) return 'error'
if (props.remainingMs <= 60_000) return 'warning'
return 'default'
}
return <Chip label={formatMs(props.remainingMs)} color={color() as any} variant="outlined" />
return <Timer remainingMs={props.remainingMs} showWarning={false} />
}
export default TimerChip

@ -0,0 +1,18 @@
import { fireEvent, render, screen } from '@solidjs/testing-library'
import { describe, expect, it, vi } from 'vitest'
import AnswerInput from './AnswerInput'
describe('AnswerInput', () => {
it('submits on Enter key', async () => {
const onSubmit = vi.fn()
const onInputValue = vi.fn()
render(() => <AnswerInput value="" onInputValue={onInputValue} onSubmit={onSubmit} />)
const input = screen.getByLabelText('Ta réponse')
await fireEvent.keyDown(input, { key: 'Enter' })
expect(onSubmit).toHaveBeenCalledTimes(1)
})
})

@ -0,0 +1,34 @@
import type { Component } from 'solid-js'
import TextField from '@suid/material/TextField'
export type AnswerInputProps = {
value: string
disabled?: boolean
label?: string
placeholder?: string
onInputValue: (nextValue: string) => void
onSubmit?: () => void
}
const AnswerInput: Component<AnswerInputProps> = (props) => {
return (
<TextField
label={props.label ?? 'Ta réponse'}
placeholder={props.placeholder}
value={props.value}
onInput={(event) => props.onInputValue((event.target as HTMLInputElement).value)}
fullWidth
disabled={props.disabled}
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
props.onSubmit?.()
}
}}
/>
)
}
export default AnswerInput

@ -0,0 +1,12 @@
import { render, screen } from '@solidjs/testing-library'
import { describe, expect, it } from 'vitest'
import AttemptIndicator from './AttemptIndicator'
describe('AttemptIndicator', () => {
it('clamps attempts and shows remaining count', () => {
render(() => <AttemptIndicator attemptsUsed={9} attemptsMax={3} />)
expect(screen.getByText('0 restant(s)')).toBeTruthy()
})
})

@ -0,0 +1,42 @@
import { For, type Component } from 'solid-js'
import Box from '@suid/material/Box'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
export type AttemptIndicatorProps = {
attemptsUsed: number
attemptsMax?: number
label?: string
remainingFormatter?: (remaining: number) => string
}
const AttemptIndicator: Component<AttemptIndicatorProps> = (props) => {
const attemptsMax = () => Math.max(1, props.attemptsMax ?? 3)
const used = () => Math.max(0, Math.min(props.attemptsUsed, attemptsMax()))
const remaining = () => Math.max(0, attemptsMax() - used())
const formatRemaining = () => props.remainingFormatter?.(remaining()) ?? `${remaining()} restant(s)`
return (
<Stack direction="row" spacing={2} alignItems="center">
<Typography sx={{ opacity: 0.8 }}>{props.label ?? 'Essais'}</Typography>
<Stack direction="row" spacing={0.5}>
<For each={Array.from({ length: attemptsMax() })}>
{(_, i) => (
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: i() < used() ? '#ef4444' : '#334155',
}}
/>
)}
</For>
</Stack>
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>{formatRemaining()}</Typography>
</Stack>
)
}
export default AttemptIndicator

@ -0,0 +1,62 @@
import type { Component, JSXElement } from 'solid-js'
import Box from '@suid/material/Box'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import Divider from '@suid/material/Divider'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
import ThemeBadge from './ThemeBadge'
export type GameCardProps = {
title?: string
theme: string
themeLabelPrefix?: string
questionText: string
timerSlot: JSXElement
scoreSlot: JSXElement
answerSlot: JSXElement
attemptSlot: JSXElement
actionsSlot: JSXElement
feedbackSlot?: JSXElement
}
const GameCard: Component<GameCardProps> = (props) => {
return (
<Stack spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{props.title ?? 'Partie'}
</Typography>
<Box sx={{ mt: 0.5 }}>
<ThemeBadge theme={props.theme} labelPrefix={props.themeLabelPrefix ?? 'Theme: '} />
</Box>
</Box>
{props.timerSlot}
{props.scoreSlot}
</Stack>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{props.questionText}
</Typography>
{props.answerSlot}
{props.attemptSlot}
{props.actionsSlot}
<Divider />
{props.feedbackSlot}
</Stack>
</CardContent>
</Card>
</Stack>
)
}
export default GameCard

@ -0,0 +1,22 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'
import { afterEach, describe, expect, it, vi } from 'vitest'
import HintButton from './HintButton'
afterEach(() => {
cleanup()
})
describe('HintButton', () => {
it('opens a confirmation dialog then confirms', async () => {
const onConfirm = vi.fn()
render(() => <HintButton onConfirm={onConfirm} />)
await fireEvent.click(screen.getByRole('button', { name: 'Indice (score réduit)' }))
expect(screen.getByText("Confirmer l'indice")).toBeTruthy()
await fireEvent.click(screen.getByRole('button', { name: 'Oui, utiliser un indice', hidden: true }))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

@ -0,0 +1,64 @@
import { createSignal, type Component } from 'solid-js'
import Button from '@suid/material/Button'
import Dialog from '@suid/material/Dialog'
import DialogActions from '@suid/material/DialogActions'
import DialogContent from '@suid/material/DialogContent'
import DialogTitle from '@suid/material/DialogTitle'
import Typography from '@suid/material/Typography'
export type HintButtonProps = {
disabled?: boolean
requiresConfirmation?: boolean
buttonLabel?: string
confirmTitle?: string
confirmMessage?: string
cancelLabel?: string
confirmLabel?: string
onConfirm: () => void
}
const HintButton: Component<HintButtonProps> = (props) => {
const [dialogOpen, setDialogOpen] = createSignal(false)
const handleClick = () => {
if (props.disabled) return
if (props.requiresConfirmation === false) {
props.onConfirm()
return
}
setDialogOpen(true)
}
const confirm = () => {
setDialogOpen(false)
props.onConfirm()
}
return (
<>
<Button variant="outlined" disabled={props.disabled} onClick={handleClick}>
{props.buttonLabel ?? 'Indice (score réduit)'}
</Button>
<Dialog open={dialogOpen()} onClose={() => setDialogOpen(false)}>
<DialogTitle>{props.confirmTitle ?? 'Confirmer l\'indice'}</DialogTitle>
<DialogContent>
<Typography>
{props.confirmMessage ?? 'Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?'}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>{props.cancelLabel ?? 'Annuler'}</Button>
<Button variant="contained" onClick={confirm}>
{props.confirmLabel ?? 'Oui, utiliser un indice'}
</Button>
</DialogActions>
</Dialog>
</>
)
}
export default HintButton

@ -0,0 +1,26 @@
import { render, screen } from '@solidjs/testing-library'
import { describe, expect, it } from 'vitest'
import LeaderboardTable from './LeaderboardTable'
describe('LeaderboardTable', () => {
it('renders rows', () => {
render(() => (
<LeaderboardTable
rows={[
{ player: 'Alice', score: 20, questions: 10, successRate: 80, durationSec: 1200 },
{ player: 'Bob', score: 18, questions: 10, successRate: 70, durationSec: 1400 },
]}
/>
))
expect(screen.getByText('Alice')).toBeTruthy()
expect(screen.getByText('Bob')).toBeTruthy()
})
it('renders empty state', () => {
render(() => <LeaderboardTable rows={[]} emptyMessage="No data" />)
expect(screen.getByText('No data')).toBeTruthy()
})
})

@ -0,0 +1,63 @@
import { For, Show, type Component } from 'solid-js'
import CircularProgress from '@suid/material/CircularProgress'
import Table from '@suid/material/Table'
import TableBody from '@suid/material/TableBody'
import TableCell from '@suid/material/TableCell'
import TableHead from '@suid/material/TableHead'
import TableRow from '@suid/material/TableRow'
import Typography from '@suid/material/Typography'
import type { LeaderboardRow } from '../types'
function formatDuration(durationSec: number): string {
const minutes = Math.floor(durationSec / 60)
return `${minutes}m`
}
export type LeaderboardTableProps = {
rows: LeaderboardRow[]
loading?: boolean
emptyMessage?: string
maxRows?: number
ariaLabel?: string
}
const LeaderboardTable: Component<LeaderboardTableProps> = (props) => {
const maxRows = () => Math.max(1, props.maxRows ?? 10)
return (
<Show when={!props.loading} fallback={<CircularProgress />}>
<Show when={props.rows.length > 0} fallback={<Typography sx={{ opacity: 0.8 }}>{props.emptyMessage ?? 'Aucun score pour le moment.'}</Typography>}>
<Table size="small" aria-label={props.ariaLabel ?? 'top-10-leaderboard'}>
<TableHead>
<TableRow>
<TableCell>Rang</TableCell>
<TableCell>Joueur</TableCell>
<TableCell align="right">Score</TableCell>
<TableCell align="right">Questions</TableCell>
<TableCell align="right">Taux de réussite</TableCell>
<TableCell align="right">Durée</TableCell>
</TableRow>
</TableHead>
<TableBody>
<For each={props.rows.slice(0, maxRows())}>
{(row, idx) => (
<TableRow>
<TableCell>#{idx() + 1}</TableCell>
<TableCell sx={{ fontWeight: 700 }}>{row.player}</TableCell>
<TableCell align="right">{row.score}</TableCell>
<TableCell align="right">{row.questions}</TableCell>
<TableCell align="right">{row.successRate}%</TableCell>
<TableCell align="right">{formatDuration(row.durationSec)}</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Show>
</Show>
)
}
export default LeaderboardTable

@ -0,0 +1,39 @@
import { render, screen } from '@solidjs/testing-library'
import { describe, expect, it, vi } from 'vitest'
import ResultsCard from './ResultsCard'
describe('ResultsCard', () => {
it('renders completed result values', () => {
render(() => (
<ResultsCard
result={{
playerName: 'Alice',
finalScore: 12,
answered: 7,
correct: 6,
successRate: 86,
durationSec: 1234,
leaderboardPosition: 2,
finishedAt: new Date().toISOString(),
}}
onPlayAgain={vi.fn()}
onViewLeaderboard={vi.fn()}
onStartGame={vi.fn()}
/>
))
expect(screen.getByText('Joueur : Alice')).toBeTruthy()
expect(screen.getByText('Score final : 12')).toBeTruthy()
expect(screen.getByText('Position leaderboard : #2')).toBeTruthy()
})
it('renders fallback when result is null', () => {
render(() => (
<ResultsCard result={null} onPlayAgain={vi.fn()} onViewLeaderboard={vi.fn()} onStartGame={vi.fn()} />
))
expect(screen.getByText('Aucune partie terminée pour le moment.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Démarrer une partie' })).toBeTruthy()
})
})

@ -0,0 +1,76 @@
import { Show, type Component } from 'solid-js'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
import type { GameResult } from '../types'
function formatDuration(durationSec: number): string {
const minutes = Math.floor(durationSec / 60)
const seconds = durationSec % 60
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
}
export type ResultsCardProps = {
result: GameResult | null
onPlayAgain: () => void
onViewLeaderboard: () => void
onStartGame: () => void
title?: string
}
const ResultsCard: Component<ResultsCardProps> = (props) => {
return (
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Show
when={props.result}
fallback={
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{props.title ?? 'Résultats'}
</Typography>
<Typography sx={{ opacity: 0.8 }}>Aucune partie terminée pour le moment.</Typography>
<Button variant="contained" onClick={props.onStartGame}>
Démarrer une partie
</Button>
</Stack>
}
>
{(last) => (
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{props.title ?? 'Résultats'}
</Typography>
<Typography sx={{ opacity: 0.8 }}>Joueur : {last().playerName}</Typography>
<Typography>Score final : {last().finalScore}</Typography>
<Typography>
Questions répondues / correctes : {last().answered} / {last().correct}
</Typography>
<Typography>Taux de réussite : {last().successRate}%</Typography>
<Typography>Durée de session : {formatDuration(last().durationSec)}</Typography>
<Typography>
Position leaderboard : {last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" onClick={props.onPlayAgain}>
Rejouer
</Button>
<Button variant="outlined" onClick={props.onViewLeaderboard}>
Voir le leaderboard
</Button>
</Stack>
</Stack>
)}
</Show>
</CardContent>
</Card>
)
}
export default ResultsCard

@ -0,0 +1,9 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
const ScoreDisplay: Component<{ score: number; labelPrefix?: string }> = (props) => {
return <Chip label={`${props.labelPrefix ?? 'Score:'} ${props.score}`} color="primary" variant="outlined" />
}
export default ScoreDisplay

@ -0,0 +1,11 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
const ThemeBadge: Component<{ theme: string; labelPrefix?: string }> = (props) => {
const label = () => (props.labelPrefix ? `${props.labelPrefix}${props.theme}` : props.theme)
return <Chip label={label()} variant="outlined" color="secondary" />
}
export default ThemeBadge

@ -0,0 +1,37 @@
import { Show, type Component } from 'solid-js'
import Chip from '@suid/material/Chip'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
import {
DEFAULT_TIMER_WARNING_TEXT,
formatMs,
getTimerColor,
getTimerWarning,
type TimerWarningText,
} from '../utils/timer'
export type TimerProps = {
remainingMs: number
showWarning?: boolean
warningText?: Partial<TimerWarningText>
}
const Timer: Component<TimerProps> = (props) => {
const mergedWarningText = () => ({ ...DEFAULT_TIMER_WARNING_TEXT, ...(props.warningText ?? {}) })
const warning = () => getTimerWarning(props.remainingMs, mergedWarningText())
return (
<Stack spacing={0.5} alignItems="flex-end">
<Chip label={formatMs(props.remainingMs)} color={getTimerColor(props.remainingMs)} variant="outlined" />
<Show when={props.showWarning !== false && warning()}>
<Typography variant="caption" sx={{ color: 'warning.main', fontWeight: 700 }}>
{warning()}
</Typography>
</Show>
</Stack>
)
}
export default Timer

@ -1,4 +1,24 @@
export type { GameResult, LeaderboardRow } from './types'
export { default as AnswerInput } from './components/AnswerInput'
export { default as AttemptIndicator } from './components/AttemptIndicator'
export { default as GameCard } from './components/GameCard'
export { default as HintButton } from './components/HintButton'
export { default as LeaderboardTable } from './components/LeaderboardTable'
export { default as ResultsCard } from './components/ResultsCard'
export { default as ScoreDisplay } from './components/ScoreDisplay'
export { default as ThemeBadge } from './components/ThemeBadge'
export { default as Timer } from './components/Timer'
export {
DEFAULT_TIMER_WARNING_TEXT,
formatMs,
getTimerColor,
getTimerWarning,
type TimerWarningText,
} from './utils/timer'
// Backward-compatible exports for old component names.
export { default as AttemptDots } from './AttemptDots'
export { default as ScoreChip } from './ScoreChip'
export { default as TimerChip } from './TimerChip'

@ -0,0 +1,18 @@
export type LeaderboardRow = {
player: string
score: number
questions: number
successRate: number
durationSec: number
}
export type GameResult = {
playerName: string
finalScore: number
answered: number
correct: number
successRate: number
durationSec: number
leaderboardPosition: number | null
finishedAt: string
}

@ -1,21 +1,19 @@
import { describe, expect, it } from 'vitest'
import { formatMs, getTimerWarning } from './Timer'
import { formatMs, getTimerWarning } from './timer'
describe('Timer helpers', () => {
it('formats remaining milliseconds as mm:ss', () => {
describe('timer utils', () => {
it('formats milliseconds as mm:ss', () => {
expect(formatMs(65_000)).toBe('1:05')
})
it('returns the 5-minute warning', () => {
it('returns warning messages at thresholds', () => {
expect(getTimerWarning(299_000)).toBe('5 minutes restantes')
})
it('returns the 1-minute warning', () => {
expect(getTimerWarning(59_000)).toBe('1 minute restante')
expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
})
it('returns the 10-second warning', () => {
expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
it('returns null when no warning should be shown', () => {
expect(getTimerWarning(301_000)).toBeNull()
})
})

@ -0,0 +1,35 @@
export function formatMs(ms: number): string {
const total = Math.ceil(ms / 1000)
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
export type TimerWarningText = {
fiveMinutes: string
oneMinute: string
tenSeconds: string
}
export const DEFAULT_TIMER_WARNING_TEXT: TimerWarningText = {
fiveMinutes: '5 minutes restantes',
oneMinute: '1 minute restante',
tenSeconds: '10 secondes restantes',
}
export function getTimerWarning(
remainingMs: number,
warningText: TimerWarningText = DEFAULT_TIMER_WARNING_TEXT
): string | null {
if (remainingMs <= 10_000) return warningText.tenSeconds
if (remainingMs <= 60_000) return warningText.oneMinute
if (remainingMs <= 300_000) return warningText.fiveMinutes
return null
}
export function getTimerColor(remainingMs: number): 'default' | 'info' | 'warning' | 'error' {
if (remainingMs <= 10_000) return 'error'
if (remainingMs <= 60_000) return 'warning'
if (remainingMs <= 300_000) return 'info'
return 'default'
}

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
import solid from 'vite-plugin-solid'
export default defineConfig({
plugins: [solid()],
test: {
environment: 'jsdom',
},
})

@ -631,12 +631,16 @@ __metadata:
languageName: node
linkType: hard
"@knowfoolery/ui-components@workspace:shared/ui-components":
"@knowfoolery/ui-components@workspace:*, @knowfoolery/ui-components@workspace:shared/ui-components":
version: 0.0.0-use.local
resolution: "@knowfoolery/ui-components@workspace:shared/ui-components"
dependencies:
"@solidjs/testing-library": "npm:^0.8.0"
"@suid/material": "npm:^0.16.0"
jsdom: "npm:^26.1.0"
solid-js: "npm:^1.9.0"
vite: "npm:^5.0.0"
vite-plugin-solid: "npm:^2.8.0"
vitest: "npm:^1.0.0"
peerDependencies:
solid-js: ^1.9.0
@ -647,6 +651,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@knowfoolery/web@workspace:apps/web"
dependencies:
"@knowfoolery/ui-components": "workspace:*"
"@solidjs/router": "npm:^0.10.0"
"@solidjs/testing-library": "npm:^0.8.0"
"@suid/icons-material": "npm:^0.7.0"

Loading…
Cancel
Save