diff --git a/.gitignore b/.gitignore index 40c0fce..ef333c7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Thumbs.db frontend/node_modules/ frontend/.yarn/ frontend/dist/ +frontend/**/dist/ frontend/build/ frontend/.cache/ frontend/.vite/ diff --git a/frontend/apps/web/node_modules/.vite/vitest/results.json b/frontend/apps/web/node_modules/.vite/vitest/results.json new file mode 100644 index 0000000..6d23824 --- /dev/null +++ b/frontend/apps/web/node_modules/.vite/vitest/results.json @@ -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}]]} \ No newline at end of file diff --git a/frontend/apps/web/src/App.tsx b/frontend/apps/web/src/App.tsx index a885ea4..1f20a8a 100644 --- a/frontend/apps/web/src/App.tsx +++ b/frontend/apps/web/src/App.tsx @@ -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 ( + diff --git a/frontend/apps/web/src/components/AppShell.tsx b/frontend/apps/web/src/components/AppShell.tsx index f57bcf3..e4193e9 100644 --- a/frontend/apps/web/src/components/AppShell.tsx +++ b/frontend/apps/web/src/components/AppShell.tsx @@ -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 = (props) => { return ( @@ -41,7 +41,7 @@ const AppShell: Component = () => { - + {props.children} ) diff --git a/frontend/apps/web/src/hooks/useAuth.ts b/frontend/apps/web/src/hooks/useAuth.ts new file mode 100644 index 0000000..94c9f42 --- /dev/null +++ b/frontend/apps/web/src/hooks/useAuth.ts @@ -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(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, + } +} diff --git a/frontend/apps/web/src/routes/Game.tsx b/frontend/apps/web/src/routes/Game.tsx index 34e0fb7..6e8f0de 100644 --- a/frontend/apps/web/src/routes/Game.tsx +++ b/frontend/apps/web/src/routes/Game.tsx @@ -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: 'C’est 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 { + 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(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 d’envoyer.') 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: c’est le seul nombre premier pair.') + setHintDialogOpen(false) + setMessage(`Indice: ${question().hint}`) } return ( @@ -86,8 +190,9 @@ export default function GameRoute() { 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() { - - @@ -115,6 +220,21 @@ export default function GameRoute() { + + setHintDialogOpen(false)}> + Confirmer l’indice + + + Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ? + + + + + + + ) } diff --git a/frontend/apps/web/src/routes/Home.tsx b/frontend/apps/web/src/routes/Home.tsx index 5506a61..347d1d1 100644 --- a/frontend/apps/web/src/routes/Home.tsx +++ b/frontend/apps/web/src/routes/Home.tsx @@ -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() { setPlayerName(e.currentTarget.value)} + onInput={(e) => setPlayerName(readInputValue(e))} error={!!error()} helperText={error() ?? '2–50 caractères'} fullWidth diff --git a/frontend/apps/web/src/routes/Leaderboard.tsx b/frontend/apps/web/src/routes/Leaderboard.tsx index d72f11e..4eb7adc 100644 --- a/frontend/apps/web/src/routes/Leaderboard.tsx +++ b/frontend/apps/web/src/routes/Leaderboard.tsx @@ -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={Aucun score pour le moment.} > - - - {(row, idx) => ( - - #{idx() + 1} - {row.player} - {row.score} - - )} - - + + + + Rang + Joueur + Score + Questions + Taux de réussite + Durée + + + + + {(row, idx) => ( + + #{idx() + 1} + {row.player} + {row.score} + {row.questions} + {row.successRate}% + {formatDuration(row.durationSec)} + + )} + + +
diff --git a/frontend/apps/web/src/routes/Profile.test.tsx b/frontend/apps/web/src/routes/Profile.test.tsx deleted file mode 100644 index a5180c1..0000000 --- a/frontend/apps/web/src/routes/Profile.test.tsx +++ /dev/null @@ -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(() => ) - expect(getByText('Profil')).toBeTruthy() - }) -}) diff --git a/frontend/apps/web/src/routes/Profile.tsx b/frontend/apps/web/src/routes/Profile.tsx index c6cfd29..355eb61 100644 --- a/frontend/apps/web/src/routes/Profile.tsx +++ b/frontend/apps/web/src/routes/Profile.tsx @@ -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 ( - - - - - Profil - - Paramètres locaux (placeholder). - - setName(e.currentTarget.value)} - fullWidth - InputLabelProps={{ style: { color: '#cbd5e1' } }} - InputProps={{ style: { color: '#e5e7eb' } }} - /> - - - - - - - - + + + + + Profil + + Connexion requise pour accéder au profil joueur. + + + + + } + > + + + + + + Statistiques joueur + + Parties jouées : {stats().gamesPlayed} + Score moyen : {stats().averageScore} + Meilleur score : {stats().bestScore} + + + + + + + + + Historique des parties + + 0} + fallback={Aucune partie enregistrée.} + > + + {(item) => ( + + + Score {item.finalScore} • Réussite {item.successRate}% • Durée {Math.floor(item.durationSec / 60)}m + + + + )} + + + + + + + + + + + Paramètres + + + setName(readInputValue(e))} + fullWidth + InputLabelProps={{ style: { color: '#cbd5e1' } }} + InputProps={{ style: { color: '#e5e7eb' } }} + /> + + + setShowHints((v) => !v)} /> + Afficher les indices pendant une partie + + + + + + + + + + + + ) } diff --git a/frontend/apps/web/src/routes/Results.tsx b/frontend/apps/web/src/routes/Results.tsx new file mode 100644 index 0000000..22fd575 --- /dev/null +++ b/frontend/apps/web/src/routes/Results.tsx @@ -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 ( + + + + + + Résultats + + Aucune partie terminée pour le moment. + + + } + > + {(last) => ( + + + Résultats + + Joueur : {last().playerName} + + Score final : {last().finalScore} + + Questions répondues / correctes : {last().answered} / {last().correct} + + Taux de réussite : {last().successRate}% + Durée de session : {formatDuration(last().durationSec)} + + Position leaderboard :{' '} + {last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'} + + + + + + + + )} + + + + + ) +} diff --git a/frontend/apps/web/src/services/api.ts b/frontend/apps/web/src/services/api.ts index af012bf..5369e05 100644 --- a/frontend/apps/web/src/services/api.ts +++ b/frontend/apps/web/src/services/api.ts @@ -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) }, } diff --git a/frontend/apps/web/src/services/session.ts b/frontend/apps/web/src/services/session.ts new file mode 100644 index 0000000..ad45868 --- /dev/null +++ b/frontend/apps/web/src/services/session.ts @@ -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(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(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(HISTORY_KEY) ?? [] +} diff --git a/frontend/apps/web/src/services/validation.test.ts b/frontend/apps/web/src/services/validation.test.ts new file mode 100644 index 0000000..3d3749a --- /dev/null +++ b/frontend/apps/web/src/services/validation.test.ts @@ -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') + }) +}) diff --git a/frontend/apps/web/src/services/validation.ts b/frontend/apps/web/src/services/validation.ts index 05741d5..887624a 100644 --- a/frontend/apps/web/src/services/validation.ts +++ b/frontend/apps/web/src/services/validation.ts @@ -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 +} diff --git a/frontend/apps/web/src/ui/Timer.test.ts b/frontend/apps/web/src/ui/Timer.test.ts new file mode 100644 index 0000000..12eb273 --- /dev/null +++ b/frontend/apps/web/src/ui/Timer.test.ts @@ -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') + }) +}) diff --git a/frontend/apps/web/src/ui/Timer.tsx b/frontend/apps/web/src/ui/Timer.tsx index 8320e67..2237c23 100644 --- a/frontend/apps/web/src/ui/Timer.tsx +++ b/frontend/apps/web/src/ui/Timer.tsx @@ -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 + const warning = () => getTimerWarning(props.remainingMs) + + return ( + + + + + {warning()} + + + + ) } export default Timer diff --git a/frontend/yarn.lock b/frontend/yarn.lock index eafddbe..9dccb72 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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