Cleanded frontend code and added tests

master
oabrivard 1 month ago
parent 06d6e42cf9
commit 9fa33ea607

1
.gitignore vendored

@ -23,6 +23,7 @@ Thumbs.db
frontend/node_modules/
frontend/.yarn/
frontend/dist/
frontend/**/dist/
frontend/build/
frontend/.cache/
frontend/.vite/

@ -0,0 +1 @@
{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/ui/Timer.test.ts",{"duration":2,"failed":false}]]}

@ -6,12 +6,14 @@ import GameRoute from './routes/Game'
import HomeRoute from './routes/Home'
import LeaderboardRoute from './routes/Leaderboard'
import ProfileRoute from './routes/Profile'
import ResultsRoute from './routes/Results'
const App: Component = () => {
return (
<Router root={AppShell}>
<Route path="/" component={HomeRoute} />
<Route path="/game" component={GameRoute} />
<Route path="/results" component={ResultsRoute} />
<Route path="/leaderboard" component={LeaderboardRoute} />
<Route path="/profile" component={ProfileRoute} />
</Router>

@ -1,4 +1,4 @@
import { A, Outlet } from '@solidjs/router'
import { A, type RouteSectionProps } from '@solidjs/router'
import type { Component } from 'solid-js'
import AppBar from '@suid/material/AppBar'
@ -14,7 +14,7 @@ import SportsEsportsIcon from '@suid/icons-material/SportsEsports'
import LeaderboardIcon from '@suid/icons-material/Leaderboard'
import PersonIcon from '@suid/icons-material/Person'
const AppShell: Component = () => {
const AppShell: Component<RouteSectionProps> = (props) => {
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#0b0e14', color: '#e6e6e6' }}>
<AppBar position="static" color="transparent" elevation={0}>
@ -41,7 +41,7 @@ const AppShell: Component = () => {
</AppBar>
<Container maxWidth="md" sx={{ py: 3 }}>
<Outlet />
{props.children}
</Container>
</Box>
)

@ -0,0 +1,29 @@
import { createSignal } from 'solid-js'
const AUTH_TOKEN_KEY = 'kf.auth.token'
function readToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(AUTH_TOKEN_KEY)
}
export function useAuth() {
const [token, setToken] = createSignal<string | null>(readToken())
const signInDemo = () => {
const next = 'demo-token'
window.localStorage.setItem(AUTH_TOKEN_KEY, next)
setToken(next)
}
const signOut = () => {
window.localStorage.removeItem(AUTH_TOKEN_KEY)
setToken(null)
}
return {
isAuthenticated: () => token() != null,
signInDemo,
signOut,
}
}

@ -1,54 +1,153 @@
import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'
import { useNavigate } from '@solidjs/router'
import { createEffect, createMemo, createSignal, onMount } from 'solid-js'
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 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'
import { useTimer } from '../hooks/useTimer'
type QuizQuestion = {
theme: string
text: string
answer: string
hint: string
}
const QUESTION: QuizQuestion = {
theme: 'Général',
text: 'Quel est le plus petit nombre premier ?',
answer: '2',
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()
}
async function estimateLeaderboardPosition(score: number, durationSec: number): Promise<number | null> {
try {
const rows = await leaderboardClient.top10()
const sorted = [...rows].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
const durationA = typeof a.durationSec === 'number' ? a.durationSec : Number.MAX_SAFE_INTEGER
const durationB = typeof b.durationSec === 'number' ? b.durationSec : Number.MAX_SAFE_INTEGER
return durationA - durationB
})
let rank = 1
for (const row of sorted) {
const rowDuration = typeof row.durationSec === 'number' ? row.durationSec : Number.MAX_SAFE_INTEGER
if (score > row.score) break
if (score === row.score && durationSec < rowDuration) break
rank += 1
}
return rank <= 10 ? rank : null
} catch {
return null
}
}
export default function GameRoute() {
// Placeholder game state until backend exists.
const [question] = createSignal({ theme: 'Général', text: 'Quel est le plus petit nombre premier ?' })
const navigate = useNavigate()
const [question] = createSignal(QUESTION)
const [answer, setAnswer] = createSignal('')
const [attempts, setAttempts] = createSignal(0)
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
const { remainingMs, isExpired, start } = useTimer(durationMs)
const startedAt = Date.now()
const { remainingMs, isExpired, start, stop } = useTimer(durationMs)
onMount(() => start())
onCleanup(() => {
// no-op; hook cleans itself
})
const attemptsLeft = createMemo(() => Math.max(0, 3 - attempts()))
const answerLocked = createMemo(() => finished() || attemptsLeft() === 0 || isExpired())
const finalize = async (finalScore: number, answered: number, correct: number) => {
if (finished()) return
setFinished(true)
stop()
const elapsedSec = Math.max(1, Math.floor((Date.now() - startedAt) / 1000))
const durationSec = Math.min(Math.floor(durationMs / 1000), elapsedSec)
const successRate = answered > 0 ? Math.round((correct / answered) * 100) : 0
const playerName = window.localStorage.getItem('kf.playerName') ?? 'Anonymous'
const leaderboardPosition = await estimateLeaderboardPosition(finalScore, durationSec)
const result = {
playerName,
finalScore,
answered,
correct,
successRate,
durationSec,
leaderboardPosition,
finishedAt: new Date().toISOString(),
}
saveLastResult(result)
appendGameHistory(result)
navigate('/results')
}
createEffect(() => {
if (isExpired() && !finished()) {
setMessage('Temps écoulé.')
const answered = attempts() > 0 ? 1 : 0
void finalize(score(), answered, 0)
}
})
const submit = () => {
if (isExpired()) {
setMessage('Session terminée (timeout).')
if (answerLocked()) return
const normalized = normalizeAnswer(answer())
if (!normalized) {
setMessage('Saisis une réponse avant denvoyer.')
return
}
const normalized = answer().trim().toLowerCase()
const correct = normalized === '2' || normalized === 'deux'
setAttempts((a) => a + 1)
const isCorrect = normalized === normalizeAnswer(question().answer) || normalized === 'deux'
const nextAttempts = attempts() + 1
setAttempts(nextAttempts)
if (correct) {
if (isCorrect) {
const delta = hintUsed() ? 1 : 2
setScore((s) => s + delta)
setMessage(`Bonne réponse (+${delta}).`)
const nextScore = score() + delta
setScore(nextScore)
setMessage(`Bonne réponse (+${delta}).`)
void finalize(nextScore, 1, 1)
return
}
if (nextAttempts >= 3) {
setMessage("Mauvaise réponse. Plus d'essais.")
void finalize(score(), 1, 0)
} else {
setMessage('Mauvaise réponse.')
}
@ -56,10 +155,15 @@ export default function GameRoute() {
setAnswer('')
}
const useHint = () => {
if (hintUsed()) return
const openHintDialog = () => {
if (hintUsed() || attempts() > 0 || answerLocked()) return
setHintDialogOpen(true)
}
const confirmHint = () => {
setHintUsed(true)
setMessage('Indice: cest le seul nombre premier pair.')
setHintDialogOpen(false)
setMessage(`Indice: ${question().hint}`)
}
return (
@ -86,8 +190,9 @@ export default function GameRoute() {
<TextField
label="Ta réponse"
value={answer()}
onInput={(e) => setAnswer(e.currentTarget.value)}
onInput={(e) => setAnswer(readInputValue(e))}
fullWidth
disabled={answerLocked()}
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
onKeyDown={(e) => {
@ -98,10 +203,10 @@ export default function GameRoute() {
<AttemptIndicator attemptsUsed={attempts()} attemptsMax={3} />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" disabled={attemptsLeft() === 0} onClick={submit}>
<Button variant="contained" disabled={answerLocked()} onClick={submit}>
Envoyer
</Button>
<Button variant="outlined" disabled={hintUsed()} onClick={useHint}>
<Button variant="outlined" disabled={hintUsed() || attempts() > 0 || answerLocked()} onClick={openHintDialog}>
Indice (score réduit)
</Button>
</Stack>
@ -115,6 +220,21 @@ export default function GameRoute() {
</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
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

@ -9,7 +9,11 @@ import Stack from '@suid/material/Stack'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
import { playerNameSchema } from '../services/validation'
import { validatePlayerName } from '../services/validation'
function readInputValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
export default function HomeRoute() {
const navigate = useNavigate()
@ -18,9 +22,9 @@ export default function HomeRoute() {
const start = () => {
const name = playerName().trim()
const parsed = playerNameSchema.safeParse(name)
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? 'Nom invalide')
const validationError = validatePlayerName(name)
if (validationError) {
setError(validationError)
return
}
@ -45,7 +49,7 @@ export default function HomeRoute() {
<TextField
label="Nom de joueur"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onInput={(e) => setPlayerName(readInputValue(e))}
error={!!error()}
helperText={error() ?? '250 caractères'}
fullWidth

@ -6,10 +6,20 @@ 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())
@ -32,17 +42,32 @@ export default function LeaderboardRoute() {
when={(items() ?? []).length > 0}
fallback={<Typography sx={{ opacity: 0.8 }}>Aucun score pour le moment.</Typography>}
>
<Stack spacing={1}>
<For each={items() ?? []}>
{(row, idx) => (
<Stack direction="row" spacing={2} alignItems="center">
<Typography sx={{ width: '3ch', opacity: 0.8 }}>#{idx() + 1}</Typography>
<Typography sx={{ flexGrow: 1, fontWeight: 700 }}>{row.player}</Typography>
<Typography sx={{ opacity: 0.9 }}>{row.score}</Typography>
</Stack>
)}
</For>
</Stack>
<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>
</CardContent>

@ -1,11 +0,0 @@
import { render } from '@solidjs/testing-library'
import { describe, expect, it } from 'vitest'
import ProfileRoute from './Profile'
describe('ProfileRoute', () => {
it('renders', () => {
const { getByText } = render(() => <ProfileRoute />)
expect(getByText('Profil')).toBeTruthy()
})
})

@ -1,15 +1,36 @@
import { createSignal } from 'solid-js'
import { For, Show, createMemo, createSignal } from 'solid-js'
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 Divider from '@suid/material/Divider'
import Stack from '@suid/material/Stack'
import Switch from '@suid/material/Switch'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
import { useAuth } from '../hooks/useAuth'
import { loadGameHistory } from '../services/session'
function readInputValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
export default function ProfileRoute() {
const { isAuthenticated, signInDemo, signOut } = useAuth()
const [name, setName] = createSignal(localStorage.getItem('kf.playerName') ?? '')
const [showHints, setShowHints] = createSignal(true)
const gameHistory = createMemo(() => loadGameHistory())
const stats = createMemo(() => {
const history = gameHistory()
const gamesPlayed = history.length
const totalScore = history.reduce((acc, item) => acc + item.finalScore, 0)
const averageScore = gamesPlayed > 0 ? Math.round((totalScore / gamesPlayed) * 10) / 10 : 0
const bestScore = history.reduce((best, item) => Math.max(best, item.finalScore), 0)
return { gamesPlayed, averageScore, bestScore }
})
const save = () => {
localStorage.setItem('kf.playerName', name().trim())
@ -17,40 +38,106 @@ export default function ProfileRoute() {
return (
<Box>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Profil
</Typography>
<Typography sx={{ opacity: 0.8 }}>Paramètres locaux (placeholder).</Typography>
<TextField
label="Nom de joueur"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
fullWidth
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" onClick={save}>
Enregistrer
</Button>
<Button
variant="outlined"
onClick={() => {
localStorage.removeItem('kf.playerName')
setName('')
}}
>
Effacer
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
<Show
when={isAuthenticated()}
fallback={
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Profil
</Typography>
<Typography sx={{ opacity: 0.8 }}>Connexion requise pour accéder au profil joueur.</Typography>
<Button variant="contained" onClick={signInDemo}>
Se connecter (mode démo)
</Button>
</Stack>
</CardContent>
</Card>
}
>
<Stack spacing={2}>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={1}>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Statistiques joueur
</Typography>
<Typography>Parties jouées : {stats().gamesPlayed}</Typography>
<Typography>Score moyen : {stats().averageScore}</Typography>
<Typography>Meilleur score : {stats().bestScore}</Typography>
</Stack>
</CardContent>
</Card>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={1}>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Historique des parties
</Typography>
<Show
when={gameHistory().length > 0}
fallback={<Typography sx={{ opacity: 0.8 }}>Aucune partie enregistrée.</Typography>}
>
<For each={gameHistory().slice(0, 5)}>
{(item) => (
<Stack spacing={0.5}>
<Typography>
Score {item.finalScore} Réussite {item.successRate}% Durée {Math.floor(item.durationSec / 60)}m
</Typography>
<Divider />
</Stack>
)}
</For>
</Show>
</Stack>
</CardContent>
</Card>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Paramètres
</Typography>
<TextField
label="Nom de joueur"
value={name()}
onInput={(e) => setName(readInputValue(e))}
fullWidth
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
/>
<Stack direction="row" spacing={1} alignItems="center">
<Switch checked={showHints()} onChange={() => setShowHints((v) => !v)} />
<Typography>Afficher les indices pendant une partie</Typography>
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" onClick={save}>
Enregistrer
</Button>
<Button
variant="outlined"
onClick={() => {
localStorage.removeItem('kf.playerName')
setName('')
}}
>
Effacer le pseudo
</Button>
<Button variant="outlined" color="error" onClick={signOut}>
Se déconnecter
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
</Stack>
</Show>
</Box>
)
}

@ -0,0 +1,74 @@
import { useNavigate } from '@solidjs/router'
import { Show, createMemo } from 'solid-js'
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>
</Box>
)
}

@ -1,6 +1,9 @@
type LeaderboardRow = {
export type LeaderboardRow = {
player: string
score: number
questions: number
successRate: number
durationSec: number
}
const baseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
@ -20,14 +23,21 @@ export const leaderboardClient = {
// For now, return a mock if API is not set.
if (!baseUrl) {
return [
{ player: 'Alice', score: 24 },
{ player: 'Bob', score: 22 },
{ player: 'Charlie', score: 20 },
{ player: 'Alice', score: 24, questions: 14, successRate: 86, durationSec: 1680 },
{ player: 'Bob', score: 22, questions: 13, successRate: 85, durationSec: 1500 },
{ player: 'Charlie', score: 20, questions: 12, successRate: 83, durationSec: 1800 },
{ player: 'Diana', score: 18, questions: 11, successRate: 82, durationSec: 1320 },
{ player: 'Eve', score: 16, questions: 10, successRate: 80, durationSec: 1620 },
{ player: 'Farah', score: 15, questions: 9, successRate: 78, durationSec: 1730 },
{ player: 'Gino', score: 14, questions: 9, successRate: 74, durationSec: 1780 },
{ player: 'Hugo', score: 13, questions: 8, successRate: 75, durationSec: 1650 },
{ player: 'Ines', score: 12, questions: 8, successRate: 71, durationSec: 1600 },
{ player: 'Jules', score: 11, questions: 7, successRate: 70, durationSec: 1710 },
]
}
// Example endpoint; adjust when gateway routes are finalized
const resp = await json<{ items?: LeaderboardRow[] }>(`/leaderboard/top10`)
return resp.items ?? []
return (resp.items ?? []).slice(0, 10)
},
}

@ -0,0 +1,50 @@
export type GameResult = {
playerName: string
finalScore: number
answered: number
correct: number
successRate: number
durationSec: number
leaderboardPosition: number | null
finishedAt: string
}
const LAST_RESULT_KEY = 'kf.lastResult'
const HISTORY_KEY = 'kf.gameHistory'
const HISTORY_LIMIT = 20
function hasStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
function readJson<T>(key: string): T | null {
if (!hasStorage()) return null
const raw = window.localStorage.getItem(key)
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
}
export function saveLastResult(result: GameResult): void {
if (!hasStorage()) return
window.localStorage.setItem(LAST_RESULT_KEY, JSON.stringify(result))
}
export function loadLastResult(): GameResult | null {
return readJson<GameResult>(LAST_RESULT_KEY)
}
export function appendGameHistory(result: GameResult): void {
if (!hasStorage()) return
const history = loadGameHistory()
const next = [result, ...history].slice(0, HISTORY_LIMIT)
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(next))
}
export function loadGameHistory(): GameResult[] {
return readJson<GameResult[]>(HISTORY_KEY) ?? []
}

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { validatePlayerName } from './validation'
describe('validatePlayerName', () => {
it('accepts a valid player name', () => {
expect(validatePlayerName('Player 42')).toBeNull()
})
it('rejects too short names', () => {
expect(validatePlayerName('A')).toBe('Le nom doit faire au moins 2 caractères')
})
it('rejects non alphanumeric characters', () => {
expect(validatePlayerName('Player@42')).toBe('Le nom doit contenir seulement lettres, chiffres et espaces')
})
})

@ -1,6 +1,9 @@
import { z } from 'zod'
const PLAYER_NAME_REGEX = /^[A-Za-z0-9 ]+$/
export const playerNameSchema = z
.string()
.min(2, 'Le nom doit faire au moins 2 caractères')
.max(50, 'Le nom doit faire au plus 50 caractères')
export function validatePlayerName(value: string): string | null {
const name = value.trim()
if (name.length < 2) return 'Le nom doit faire au moins 2 caractères'
if (name.length > 50) return 'Le nom doit faire au plus 50 caractères'
if (!PLAYER_NAME_REGEX.test(name)) return 'Le nom doit contenir seulement lettres, chiffres et espaces'
return null
}

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { formatMs, getTimerWarning } from './Timer'
describe('Timer helpers', () => {
it('formats remaining milliseconds as mm:ss', () => {
expect(formatMs(65_000)).toBe('1:05')
})
it('returns the 5-minute warning', () => {
expect(getTimerWarning(299_000)).toBe('5 minutes restantes')
})
it('returns the 1-minute warning', () => {
expect(getTimerWarning(59_000)).toBe('1 minute restante')
})
it('returns the 10-second warning', () => {
expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
})
})

@ -1,22 +1,43 @@
import type { Component } from 'solid-js'
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'
function formatMs(ms: number) {
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'
}
return <Chip label={formatMs(props.remainingMs)} color={color() as any} variant="outlined" />
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

@ -5,6 +5,19 @@ __metadata:
version: 8
cacheKey: 10c0
"@asamuzakjp/css-color@npm:^3.2.0":
version: 3.2.0
resolution: "@asamuzakjp/css-color@npm:3.2.0"
dependencies:
"@csstools/css-calc": "npm:^2.1.3"
"@csstools/css-color-parser": "npm:^3.0.9"
"@csstools/css-parser-algorithms": "npm:^3.0.4"
"@csstools/css-tokenizer": "npm:^3.0.3"
lru-cache: "npm:^10.4.3"
checksum: a4bf1c831751b1fae46b437e37e8a38c0b5bd58d23230157ae210bd1e905fe509b89b7c243e63d1522d852668a6292ed730a160e21342772b4e5b7b8ea14c092
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/code-frame@npm:7.29.0"
@ -214,6 +227,52 @@ __metadata:
languageName: node
linkType: hard
"@csstools/color-helpers@npm:^5.1.0":
version: 5.1.0
resolution: "@csstools/color-helpers@npm:5.1.0"
checksum: b7f99d2e455cf1c9b41a67a5327d5d02888cd5c8802a68b1887dffef537d9d4bc66b3c10c1e62b40bbed638b6c1d60b85a232f904ed7b39809c4029cb36567db
languageName: node
linkType: hard
"@csstools/css-calc@npm:^2.1.3, @csstools/css-calc@npm:^2.1.4":
version: 2.1.4
resolution: "@csstools/css-calc@npm:2.1.4"
peerDependencies:
"@csstools/css-parser-algorithms": ^3.0.5
"@csstools/css-tokenizer": ^3.0.4
checksum: 42ce5793e55ec4d772083808a11e9fb2dfe36db3ec168713069a276b4c3882205b3507c4680224c28a5d35fe0bc2d308c77f8f2c39c7c09aad8747708eb8ddd8
languageName: node
linkType: hard
"@csstools/css-color-parser@npm:^3.0.9":
version: 3.1.0
resolution: "@csstools/css-color-parser@npm:3.1.0"
dependencies:
"@csstools/color-helpers": "npm:^5.1.0"
"@csstools/css-calc": "npm:^2.1.4"
peerDependencies:
"@csstools/css-parser-algorithms": ^3.0.5
"@csstools/css-tokenizer": ^3.0.4
checksum: 0e0c670ad54ec8ec4d9b07568b80defd83b9482191f5e8ca84ab546b7be6db5d7cc2ba7ac9fae54488b129a4be235d6183d3aab4416fec5e89351f73af4222c5
languageName: node
linkType: hard
"@csstools/css-parser-algorithms@npm:^3.0.4":
version: 3.0.5
resolution: "@csstools/css-parser-algorithms@npm:3.0.5"
peerDependencies:
"@csstools/css-tokenizer": ^3.0.4
checksum: d9a1c888bd43849ae3437ca39251d5c95d2c8fd6b5ccdb7c45491dfd2c1cbdc3075645e80901d120e4d2c1993db9a5b2d83793b779dbbabcfb132adb142eb7f7
languageName: node
linkType: hard
"@csstools/css-tokenizer@npm:^3.0.3":
version: 3.0.4
resolution: "@csstools/css-tokenizer@npm:3.0.4"
checksum: 3b589f8e9942075a642213b389bab75a2d50d05d203727fcdac6827648a5572674caff07907eff3f9a2389d86a4ee47308fafe4f8588f4a77b7167c588d2559f
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.21.5":
version: 0.21.5
resolution: "@esbuild/aix-ppc64@npm:0.21.5"
@ -592,11 +651,13 @@ __metadata:
"@solidjs/testing-library": "npm:^0.8.0"
"@suid/icons-material": "npm:^0.7.0"
"@suid/material": "npm:^0.16.0"
jsdom: "npm:^26.1.0"
solid-js: "npm:^1.9.0"
typescript: "npm:^5.5.4"
vite: "npm:^5.0.0"
vite-plugin-solid: "npm:^2.8.0"
vitest: "npm:^1.0.0"
zod: "npm:^3.25.76"
languageName: unknown
linkType: soft
@ -1515,6 +1576,16 @@ __metadata:
languageName: node
linkType: hard
"cssstyle@npm:^4.2.1":
version: 4.6.0
resolution: "cssstyle@npm:4.6.0"
dependencies:
"@asamuzakjp/css-color": "npm:^3.2.0"
rrweb-cssom: "npm:^0.8.0"
checksum: 71add1b0ffafa1bedbef6855db6189b9523d3320e015a0bf3fbd504760efb9a81e1f1a225228d5fa892ee58e56d06994ca372e7f4e461cda7c4c9985fe075f65
languageName: node
linkType: hard
"csstype@npm:^3.1.0, csstype@npm:^3.1.3":
version: 3.2.3
resolution: "csstype@npm:3.2.3"
@ -1522,6 +1593,16 @@ __metadata:
languageName: node
linkType: hard
"data-urls@npm:^5.0.0":
version: 5.0.0
resolution: "data-urls@npm:5.0.0"
dependencies:
whatwg-mimetype: "npm:^4.0.0"
whatwg-url: "npm:^14.0.0"
checksum: 1b894d7d41c861f3a4ed2ae9b1c3f0909d4575ada02e36d3d3bc584bdd84278e20709070c79c3b3bff7ac98598cb191eb3e86a89a79ea4ee1ef360e1694f92ad
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.3":
version: 4.4.3
resolution: "debug@npm:4.4.3"
@ -1534,6 +1615,13 @@ __metadata:
languageName: node
linkType: hard
"decimal.js@npm:^10.5.0":
version: 10.6.0
resolution: "decimal.js@npm:10.6.0"
checksum: 07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa
languageName: node
linkType: hard
"deep-eql@npm:^4.1.3":
version: 4.1.4
resolution: "deep-eql@npm:4.1.4"
@ -2045,6 +2133,15 @@ __metadata:
languageName: node
linkType: hard
"html-encoding-sniffer@npm:^4.0.0":
version: 4.0.0
resolution: "html-encoding-sniffer@npm:4.0.0"
dependencies:
whatwg-encoding: "npm:^3.1.1"
checksum: 523398055dc61ac9b34718a719cb4aa691e4166f29187e211e1607de63dc25ac7af52ca7c9aead0c4b3c0415ffecb17326396e1202e2e86ff4bca4c0ee4c6140
languageName: node
linkType: hard
"html-entities@npm:2.3.3":
version: 2.3.3
resolution: "html-entities@npm:2.3.3"
@ -2066,7 +2163,7 @@ __metadata:
languageName: node
linkType: hard
"http-proxy-agent@npm:^7.0.0":
"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2":
version: 7.0.2
resolution: "http-proxy-agent@npm:7.0.2"
dependencies:
@ -2076,7 +2173,7 @@ __metadata:
languageName: node
linkType: hard
"https-proxy-agent@npm:^7.0.1":
"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6":
version: 7.0.6
resolution: "https-proxy-agent@npm:7.0.6"
dependencies:
@ -2093,7 +2190,7 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:^0.6.2":
"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2":
version: 0.6.3
resolution: "iconv-lite@npm:0.6.3"
dependencies:
@ -2172,6 +2269,13 @@ __metadata:
languageName: node
linkType: hard
"is-potential-custom-element-name@npm:^1.0.1":
version: 1.0.1
resolution: "is-potential-custom-element-name@npm:1.0.1"
checksum: b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9
languageName: node
linkType: hard
"is-stream@npm:^3.0.0":
version: 3.0.0
resolution: "is-stream@npm:3.0.0"
@ -2234,6 +2338,39 @@ __metadata:
languageName: node
linkType: hard
"jsdom@npm:^26.1.0":
version: 26.1.0
resolution: "jsdom@npm:26.1.0"
dependencies:
cssstyle: "npm:^4.2.1"
data-urls: "npm:^5.0.0"
decimal.js: "npm:^10.5.0"
html-encoding-sniffer: "npm:^4.0.0"
http-proxy-agent: "npm:^7.0.2"
https-proxy-agent: "npm:^7.0.6"
is-potential-custom-element-name: "npm:^1.0.1"
nwsapi: "npm:^2.2.16"
parse5: "npm:^7.2.1"
rrweb-cssom: "npm:^0.8.0"
saxes: "npm:^6.0.0"
symbol-tree: "npm:^3.2.4"
tough-cookie: "npm:^5.1.1"
w3c-xmlserializer: "npm:^5.0.0"
webidl-conversions: "npm:^7.0.0"
whatwg-encoding: "npm:^3.1.1"
whatwg-mimetype: "npm:^4.0.0"
whatwg-url: "npm:^14.1.1"
ws: "npm:^8.18.0"
xml-name-validator: "npm:^5.0.0"
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
checksum: 5b14a5bc32ce077a06fb42d1ab95b1191afa5cbbce8859e3b96831c5143becbbcbf0511d4d4934e922d2901443ced2cdc3b734c1cf30b5f73b3e067ce457d0f4
languageName: node
linkType: hard
"jsesc@npm:^3.0.2":
version: 3.1.0
resolution: "jsesc@npm:3.1.0"
@ -2358,6 +2495,13 @@ __metadata:
languageName: node
linkType: hard
"lru-cache@npm:^10.4.3":
version: 10.4.3
resolution: "lru-cache@npm:10.4.3"
checksum: ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb
languageName: node
linkType: hard
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
version: 11.2.5
resolution: "lru-cache@npm:11.2.5"
@ -2626,6 +2770,13 @@ __metadata:
languageName: node
linkType: hard
"nwsapi@npm:^2.2.16":
version: 2.2.23
resolution: "nwsapi@npm:2.2.23"
checksum: e44bfc9246baf659581206ed716d291a1905185247795fb8a302cb09315c943a31023b4ac4d026a5eaf32b2def51d77b3d0f9ebf4f3d35f70e105fcb6447c76e
languageName: node
linkType: hard
"onetime@npm:^6.0.0":
version: 6.0.0
resolution: "onetime@npm:6.0.0"
@ -2692,7 +2843,7 @@ __metadata:
languageName: node
linkType: hard
"parse5@npm:^7.1.2":
"parse5@npm:^7.1.2, parse5@npm:^7.2.1":
version: 7.3.0
resolution: "parse5@npm:7.3.0"
dependencies:
@ -2844,7 +2995,7 @@ __metadata:
languageName: node
linkType: hard
"punycode@npm:^2.1.0":
"punycode@npm:^2.1.0, punycode@npm:^2.3.1":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
checksum: 14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9
@ -2969,6 +3120,13 @@ __metadata:
languageName: node
linkType: hard
"rrweb-cssom@npm:^0.8.0":
version: 0.8.0
resolution: "rrweb-cssom@npm:0.8.0"
checksum: 56f2bfd56733adb92c0b56e274c43f864b8dd48784d6fe946ef5ff8d438234015e59ad837fc2ad54714b6421384141c1add4eb569e72054e350d1f8a50b8ac7b
languageName: node
linkType: hard
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
@ -2976,6 +3134,15 @@ __metadata:
languageName: node
linkType: hard
"saxes@npm:^6.0.0":
version: 6.0.0
resolution: "saxes@npm:6.0.0"
dependencies:
xmlchars: "npm:^2.2.0"
checksum: 3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74
languageName: node
linkType: hard
"semver@npm:^6.3.1":
version: 6.3.1
resolution: "semver@npm:6.3.1"
@ -3163,6 +3330,13 @@ __metadata:
languageName: node
linkType: hard
"symbol-tree@npm:^3.2.4":
version: 3.2.4
resolution: "symbol-tree@npm:3.2.4"
checksum: dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509
languageName: node
linkType: hard
"tar@npm:^7.5.4":
version: 7.5.7
resolution: "tar@npm:7.5.7"
@ -3207,6 +3381,42 @@ __metadata:
languageName: node
linkType: hard
"tldts-core@npm:^6.1.86":
version: 6.1.86
resolution: "tldts-core@npm:6.1.86"
checksum: 8133c29375f3f99f88fce5f4d62f6ecb9532b106f31e5423b27c1eb1b6e711bd41875184a456819ceaed5c8b94f43911b1ad57e25c6eb86e1fc201228ff7e2af
languageName: node
linkType: hard
"tldts@npm:^6.1.32":
version: 6.1.86
resolution: "tldts@npm:6.1.86"
dependencies:
tldts-core: "npm:^6.1.86"
bin:
tldts: bin/cli.js
checksum: 27ae7526d9d78cb97b2de3f4d102e0b4321d1ccff0648a7bb0e039ed54acbce86bacdcd9cd3c14310e519b457854e7bafbef1f529f58a1e217a737ced63f0940
languageName: node
linkType: hard
"tough-cookie@npm:^5.1.1":
version: 5.1.2
resolution: "tough-cookie@npm:5.1.2"
dependencies:
tldts: "npm:^6.1.32"
checksum: 5f95023a47de0f30a902bba951664b359725597d8adeabc66a0b93a931c3af801e1e697dae4b8c21a012056c0ea88bd2bf4dfe66b2adcf8e2f42cd9796fe0626
languageName: node
linkType: hard
"tr46@npm:^5.1.0":
version: 5.1.1
resolution: "tr46@npm:5.1.1"
dependencies:
punycode: "npm:^2.3.1"
checksum: ae270e194d52ec67ebd695c1a42876e0f19b96e4aca2ab464ab1d9d17dc3acd3e18764f5034c93897db73421563be27c70c98359c4501136a497e46deda5d5ec
languageName: node
linkType: hard
"ts-api-utils@npm:^2.4.0":
version: 2.4.0
resolution: "ts-api-utils@npm:2.4.0"
@ -3456,6 +3666,48 @@ __metadata:
languageName: node
linkType: hard
"w3c-xmlserializer@npm:^5.0.0":
version: 5.0.0
resolution: "w3c-xmlserializer@npm:5.0.0"
dependencies:
xml-name-validator: "npm:^5.0.0"
checksum: 8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b
languageName: node
linkType: hard
"webidl-conversions@npm:^7.0.0":
version: 7.0.0
resolution: "webidl-conversions@npm:7.0.0"
checksum: 228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4
languageName: node
linkType: hard
"whatwg-encoding@npm:^3.1.1":
version: 3.1.1
resolution: "whatwg-encoding@npm:3.1.1"
dependencies:
iconv-lite: "npm:0.6.3"
checksum: 273b5f441c2f7fda3368a496c3009edbaa5e43b71b09728f90425e7f487e5cef9eb2b846a31bd760dd8077739c26faf6b5ca43a5f24033172b003b72cf61a93e
languageName: node
linkType: hard
"whatwg-mimetype@npm:^4.0.0":
version: 4.0.0
resolution: "whatwg-mimetype@npm:4.0.0"
checksum: a773cdc8126b514d790bdae7052e8bf242970cebd84af62fb2f35a33411e78e981f6c0ab9ed1fe6ec5071b09d5340ac9178e05b52d35a9c4bcf558ba1b1551df
languageName: node
linkType: hard
"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.1":
version: 14.2.0
resolution: "whatwg-url@npm:14.2.0"
dependencies:
tr46: "npm:^5.1.0"
webidl-conversions: "npm:^7.0.0"
checksum: f746fc2f4c906607d09537de1227b13f9494c171141e5427ed7d2c0dd0b6a48b43d8e71abaae57d368d0c06b673fd8ec63550b32ad5ed64990c7b0266c2b4272
languageName: node
linkType: hard
"which@npm:^2.0.1":
version: 2.0.2
resolution: "which@npm:2.0.2"
@ -3497,6 +3749,35 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:^8.18.0":
version: 8.19.0
resolution: "ws@npm:8.19.0"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2"
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
checksum: 4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04
languageName: node
linkType: hard
"xml-name-validator@npm:^5.0.0":
version: 5.0.0
resolution: "xml-name-validator@npm:5.0.0"
checksum: 3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5
languageName: node
linkType: hard
"xmlchars@npm:^2.2.0":
version: 2.2.0
resolution: "xmlchars@npm:2.2.0"
checksum: b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593
languageName: node
linkType: hard
"yallist@npm:^3.0.2":
version: 3.1.1
resolution: "yallist@npm:3.1.1"
@ -3531,3 +3812,10 @@ __metadata:
checksum: 36d4793e9cf7060f9da543baf67c55e354f4862c8d3d34de1a1b1d7c382d44171315cc54abf84d8900b8113d742b830108a1434f4898fb244f9b7e8426d4b8f5
languageName: node
linkType: hard
"zod@npm:^3.25.76":
version: 3.25.76
resolution: "zod@npm:3.25.76"
checksum: 5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c
languageName: node
linkType: hard

Loading…
Cancel
Save