From 1b438b0ad1fd1b0e19d8b3665f9784d8fef19bd5 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Wed, 11 Feb 2026 09:30:52 +0100 Subject: [PATCH] Finished step '4.2 Shared UI Components' --- .../non-functional-requirements.md | 5 +- .../application-architecture.md | 10 +- docs/4_work_plan/overall-plan.md | 142 +++++++++--------- .../node_modules/.vite/vitest/results.json | 2 +- frontend/apps/web/package.json | 1 + frontend/apps/web/src/routes/Game.test.tsx | 19 +++ frontend/apps/web/src/routes/Game.tsx | 140 ++++++----------- .../apps/web/src/routes/Leaderboard.test.tsx | 16 ++ frontend/apps/web/src/routes/Leaderboard.tsx | 48 +----- frontend/apps/web/src/routes/Results.test.tsx | 34 +++++ frontend/apps/web/src/routes/Results.tsx | 68 ++------- frontend/apps/web/src/ui/AttemptIndicator.tsx | 32 ---- frontend/apps/web/src/ui/ScoreDisplay.tsx | 9 -- frontend/apps/web/src/ui/Timer.tsx | 43 ------ frontend/apps/web/vitest.config.ts | 2 + .../node_modules/.vite/vitest/results.json | 1 + frontend/shared/ui-components/package.json | 6 +- .../shared/ui-components/src/AttemptDots.tsx | 27 +--- .../shared/ui-components/src/ScoreChip.tsx | 4 +- .../shared/ui-components/src/TimerChip.tsx | 17 +-- .../src/components/AnswerInput.test.tsx | 18 +++ .../src/components/AnswerInput.tsx | 34 +++++ .../src/components/AttemptIndicator.test.tsx | 12 ++ .../src/components/AttemptIndicator.tsx | 42 ++++++ .../ui-components/src/components/GameCard.tsx | 62 ++++++++ .../src/components/HintButton.test.tsx | 22 +++ .../src/components/HintButton.tsx | 64 ++++++++ .../src/components/LeaderboardTable.test.tsx | 26 ++++ .../src/components/LeaderboardTable.tsx | 63 ++++++++ .../src/components/ResultsCard.test.tsx | 39 +++++ .../src/components/ResultsCard.tsx | 76 ++++++++++ .../src/components/ScoreDisplay.tsx | 9 ++ .../src/components/ThemeBadge.tsx | 11 ++ .../ui-components/src/components/Timer.tsx | 37 +++++ frontend/shared/ui-components/src/index.ts | 22 ++- frontend/shared/ui-components/src/types.ts | 18 +++ .../ui-components/src/utils/timer.test.ts} | 16 +- .../shared/ui-components/src/utils/timer.ts | 35 +++++ .../shared/ui-components/vitest.config.ts | 9 ++ frontend/yarn.lock | 7 +- 40 files changed, 838 insertions(+), 410 deletions(-) create mode 100644 frontend/apps/web/src/routes/Game.test.tsx create mode 100644 frontend/apps/web/src/routes/Leaderboard.test.tsx create mode 100644 frontend/apps/web/src/routes/Results.test.tsx delete mode 100644 frontend/apps/web/src/ui/AttemptIndicator.tsx delete mode 100644 frontend/apps/web/src/ui/ScoreDisplay.tsx delete mode 100644 frontend/apps/web/src/ui/Timer.tsx create mode 100644 frontend/shared/ui-components/node_modules/.vite/vitest/results.json create mode 100644 frontend/shared/ui-components/src/components/AnswerInput.test.tsx create mode 100644 frontend/shared/ui-components/src/components/AnswerInput.tsx create mode 100644 frontend/shared/ui-components/src/components/AttemptIndicator.test.tsx create mode 100644 frontend/shared/ui-components/src/components/AttemptIndicator.tsx create mode 100644 frontend/shared/ui-components/src/components/GameCard.tsx create mode 100644 frontend/shared/ui-components/src/components/HintButton.test.tsx create mode 100644 frontend/shared/ui-components/src/components/HintButton.tsx create mode 100644 frontend/shared/ui-components/src/components/LeaderboardTable.test.tsx create mode 100644 frontend/shared/ui-components/src/components/LeaderboardTable.tsx create mode 100644 frontend/shared/ui-components/src/components/ResultsCard.test.tsx create mode 100644 frontend/shared/ui-components/src/components/ResultsCard.tsx create mode 100644 frontend/shared/ui-components/src/components/ScoreDisplay.tsx create mode 100644 frontend/shared/ui-components/src/components/ThemeBadge.tsx create mode 100644 frontend/shared/ui-components/src/components/Timer.tsx create mode 100644 frontend/shared/ui-components/src/types.ts rename frontend/{apps/web/src/ui/Timer.test.ts => shared/ui-components/src/utils/timer.test.ts} (50%) create mode 100644 frontend/shared/ui-components/src/utils/timer.ts create mode 100644 frontend/shared/ui-components/vitest.config.ts diff --git a/docs/1_requirements/non-functional-requirements.md b/docs/1_requirements/non-functional-requirements.md index 5e8183f..ea2cda1 100644 --- a/docs/1_requirements/non-functional-requirements.md +++ b/docs/1_requirements/non-functional-requirements.md @@ -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 diff --git a/docs/2_architecture/application-architecture.md b/docs/2_architecture/application-architecture.md index 515d0c3..c868488 100644 --- a/docs/2_architecture/application-architecture.md +++ b/docs/2_architecture/application-architecture.md @@ -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: diff --git a/docs/4_work_plan/overall-plan.md b/docs/4_work_plan/overall-plan.md index d884dc9..eb65c9f 100644 --- a/docs/4_work_plan/overall-plan.md +++ b/docs/4_work_plan/overall-plan.md @@ -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/ +``` diff --git a/frontend/apps/web/node_modules/.vite/vitest/results.json b/frontend/apps/web/node_modules/.vite/vitest/results.json index 6d23824..6266107 100644 --- a/frontend/apps/web/node_modules/.vite/vitest/results.json +++ b/frontend/apps/web/node_modules/.vite/vitest/results.json @@ -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}]]} \ No newline at end of file +{"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}]]} \ No newline at end of file diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index 5abea82..da6bdaf 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -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", diff --git a/frontend/apps/web/src/routes/Game.test.tsx b/frontend/apps/web/src/routes/Game.test.tsx new file mode 100644 index 0000000..f92d281 --- /dev/null +++ b/frontend/apps/web/src/routes/Game.test.tsx @@ -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(() => ) + + 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() + }) +}) diff --git a/frontend/apps/web/src/routes/Game.tsx b/frontend/apps/web/src/routes/Game.tsx index 6e8f0de..87cdab2 100644 --- a/frontend/apps/web/src/routes/Game.tsx +++ b/frontend/apps/web/src/routes/Game.tsx @@ -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: '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() } @@ -76,7 +68,6 @@ export default function GameRoute() { 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 @@ -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 ( - - - - - - Partie - - Thème : {question().theme} - - - - - - - - - - {question().text} - - - setAnswer(readInputValue(e))} - fullWidth - disabled={answerLocked()} - InputLabelProps={{ style: { color: '#cbd5e1' } }} - InputProps={{ style: { color: '#e5e7eb' } }} - onKeyDown={(e) => { - if (e.key === 'Enter') submit() - }} - /> - - - - - - - - - - - {message() && {message()}} - {attemptsLeft() === 0 && Plus d'essais.} - {isExpired() && Temps écoulé.} - - - - - - setHintDialogOpen(false)}> - Confirmer l’indice - - - Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ? - - - - - - - - + 0 || answerLocked()} + buttonLabel="Indice (score réduit)" + confirmTitle="Confirmer l’indice" + 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} + /> + + } + feedbackSlot={ + <> + {message() && {message()}} + {attemptsLeft() === 0 && Plus d'essais.} + {isExpired() && Temps écoulé.} + + } + /> ) } diff --git a/frontend/apps/web/src/routes/Leaderboard.test.tsx b/frontend/apps/web/src/routes/Leaderboard.test.tsx new file mode 100644 index 0000000..a50a9e2 --- /dev/null +++ b/frontend/apps/web/src/routes/Leaderboard.test.tsx @@ -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(() => ) + + await waitFor(() => { + expect(screen.getByText('Alice')).toBeTruthy() + }) + + expect(screen.getByRole('table', { name: 'top-10-leaderboard' })).toBeTruthy() + }) +}) diff --git a/frontend/apps/web/src/routes/Leaderboard.tsx b/frontend/apps/web/src/routes/Leaderboard.tsx index 4eb7adc..75480a6 100644 --- a/frontend/apps/web/src/routes/Leaderboard.tsx +++ b/frontend/apps/web/src/routes/Leaderboard.tsx @@ -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() { - }> - 0} - fallback={Aucun score pour le moment.} - > - - - - 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/Results.test.tsx b/frontend/apps/web/src/routes/Results.test.tsx new file mode 100644 index 0000000..6cb9b13 --- /dev/null +++ b/frontend/apps/web/src/routes/Results.test.tsx @@ -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(() => ) + + expect(screen.getByText('Joueur : Alice')).toBeTruthy() + expect(screen.getByText('Score final : 10')).toBeTruthy() + expect(screen.getByText('Position leaderboard : #3')).toBeTruthy() + }) +}) diff --git a/frontend/apps/web/src/routes/Results.tsx b/frontend/apps/web/src/routes/Results.tsx index 22fd575..522b833 100644 --- a/frontend/apps/web/src/routes/Results.tsx +++ b/frontend/apps/web/src/routes/Results.tsx @@ -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 ( - - - - - 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'} - - - - - - - - )} - - - + navigate('/game')} + onViewLeaderboard={() => navigate('/leaderboard')} + onStartGame={() => navigate('/game')} + title="Résultats" + /> ) } diff --git a/frontend/apps/web/src/ui/AttemptIndicator.tsx b/frontend/apps/web/src/ui/AttemptIndicator.tsx deleted file mode 100644 index 1ebe8eb..0000000 --- a/frontend/apps/web/src/ui/AttemptIndicator.tsx +++ /dev/null @@ -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 ( - - Essais - - {Array.from({ length: props.attemptsMax }).map((_, i) => ( - - ))} - - - {Math.max(0, props.attemptsMax - used())} restant(s) - - - ) -} - -export default AttemptIndicator diff --git a/frontend/apps/web/src/ui/ScoreDisplay.tsx b/frontend/apps/web/src/ui/ScoreDisplay.tsx deleted file mode 100644 index 5280ed3..0000000 --- a/frontend/apps/web/src/ui/ScoreDisplay.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { Component } from 'solid-js' - -import Chip from '@suid/material/Chip' - -const ScoreDisplay: Component<{ score: number }> = (props) => { - return -} - -export default ScoreDisplay diff --git a/frontend/apps/web/src/ui/Timer.tsx b/frontend/apps/web/src/ui/Timer.tsx deleted file mode 100644 index 2237c23..0000000 --- a/frontend/apps/web/src/ui/Timer.tsx +++ /dev/null @@ -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 ( - - - - - {warning()} - - - - ) -} - -export default Timer diff --git a/frontend/apps/web/vitest.config.ts b/frontend/apps/web/vitest.config.ts index 68e3449..468fa38 100644 --- a/frontend/apps/web/vitest.config.ts +++ b/frontend/apps/web/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from 'vitest/config' +import solid from 'vite-plugin-solid' export default defineConfig({ + plugins: [solid()], test: { environment: 'jsdom', }, diff --git a/frontend/shared/ui-components/node_modules/.vite/vitest/results.json b/frontend/shared/ui-components/node_modules/.vite/vitest/results.json new file mode 100644 index 0000000..929932b --- /dev/null +++ b/frontend/shared/ui-components/node_modules/.vite/vitest/results.json @@ -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}]]} \ No newline at end of file diff --git a/frontend/shared/ui-components/package.json b/frontend/shared/ui-components/package.json index ef70ece..00ebf68 100644 --- a/frontend/shared/ui-components/package.json +++ b/frontend/shared/ui-components/package.json @@ -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": { diff --git a/frontend/shared/ui-components/src/AttemptDots.tsx b/frontend/shared/ui-components/src/AttemptDots.tsx index 42517bf..8f61013 100644 --- a/frontend/shared/ui-components/src/AttemptDots.tsx +++ b/frontend/shared/ui-components/src/AttemptDots.tsx @@ -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 ( - - Essais - - {Array.from({ length: props.attemptsMax }).map((_, i) => ( - - ))} - - - {Math.max(0, props.attemptsMax - used())} restant(s) - - - ) + return } export default AttemptDots diff --git a/frontend/shared/ui-components/src/ScoreChip.tsx b/frontend/shared/ui-components/src/ScoreChip.tsx index 69f6f71..6e54d59 100644 --- a/frontend/shared/ui-components/src/ScoreChip.tsx +++ b/frontend/shared/ui-components/src/ScoreChip.tsx @@ -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 + return } export default ScoreChip diff --git a/frontend/shared/ui-components/src/TimerChip.tsx b/frontend/shared/ui-components/src/TimerChip.tsx index c1be83a..3142fae 100644 --- a/frontend/shared/ui-components/src/TimerChip.tsx +++ b/frontend/shared/ui-components/src/TimerChip.tsx @@ -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 + return } export default TimerChip diff --git a/frontend/shared/ui-components/src/components/AnswerInput.test.tsx b/frontend/shared/ui-components/src/components/AnswerInput.test.tsx new file mode 100644 index 0000000..78ab339 --- /dev/null +++ b/frontend/shared/ui-components/src/components/AnswerInput.test.tsx @@ -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(() => ) + + const input = screen.getByLabelText('Ta réponse') + await fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSubmit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/shared/ui-components/src/components/AnswerInput.tsx b/frontend/shared/ui-components/src/components/AnswerInput.tsx new file mode 100644 index 0000000..05041ca --- /dev/null +++ b/frontend/shared/ui-components/src/components/AnswerInput.tsx @@ -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 = (props) => { + return ( + 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 diff --git a/frontend/shared/ui-components/src/components/AttemptIndicator.test.tsx b/frontend/shared/ui-components/src/components/AttemptIndicator.test.tsx new file mode 100644 index 0000000..e87bbc3 --- /dev/null +++ b/frontend/shared/ui-components/src/components/AttemptIndicator.test.tsx @@ -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(() => ) + + expect(screen.getByText('0 restant(s)')).toBeTruthy() + }) +}) diff --git a/frontend/shared/ui-components/src/components/AttemptIndicator.tsx b/frontend/shared/ui-components/src/components/AttemptIndicator.tsx new file mode 100644 index 0000000..db1f727 --- /dev/null +++ b/frontend/shared/ui-components/src/components/AttemptIndicator.tsx @@ -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 = (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 ( + + {props.label ?? 'Essais'} + + + {(_, i) => ( + + )} + + + {formatRemaining()} + + ) +} + +export default AttemptIndicator diff --git a/frontend/shared/ui-components/src/components/GameCard.tsx b/frontend/shared/ui-components/src/components/GameCard.tsx new file mode 100644 index 0000000..220652e --- /dev/null +++ b/frontend/shared/ui-components/src/components/GameCard.tsx @@ -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 = (props) => { + return ( + + + + + {props.title ?? 'Partie'} + + + + + + {props.timerSlot} + {props.scoreSlot} + + + + + + + {props.questionText} + + + {props.answerSlot} + {props.attemptSlot} + {props.actionsSlot} + + + + {props.feedbackSlot} + + + + + ) +} + +export default GameCard diff --git a/frontend/shared/ui-components/src/components/HintButton.test.tsx b/frontend/shared/ui-components/src/components/HintButton.test.tsx new file mode 100644 index 0000000..a834b22 --- /dev/null +++ b/frontend/shared/ui-components/src/components/HintButton.test.tsx @@ -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(() => ) + + 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) + }) +}) diff --git a/frontend/shared/ui-components/src/components/HintButton.tsx b/frontend/shared/ui-components/src/components/HintButton.tsx new file mode 100644 index 0000000..ffb22cf --- /dev/null +++ b/frontend/shared/ui-components/src/components/HintButton.tsx @@ -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 = (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 ( + <> + + + setDialogOpen(false)}> + {props.confirmTitle ?? 'Confirmer l\'indice'} + + + {props.confirmMessage ?? 'Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?'} + + + + + + + + + ) +} + +export default HintButton diff --git a/frontend/shared/ui-components/src/components/LeaderboardTable.test.tsx b/frontend/shared/ui-components/src/components/LeaderboardTable.test.tsx new file mode 100644 index 0000000..d50e606 --- /dev/null +++ b/frontend/shared/ui-components/src/components/LeaderboardTable.test.tsx @@ -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(() => ( + + )) + + expect(screen.getByText('Alice')).toBeTruthy() + expect(screen.getByText('Bob')).toBeTruthy() + }) + + it('renders empty state', () => { + render(() => ) + + expect(screen.getByText('No data')).toBeTruthy() + }) +}) diff --git a/frontend/shared/ui-components/src/components/LeaderboardTable.tsx b/frontend/shared/ui-components/src/components/LeaderboardTable.tsx new file mode 100644 index 0000000..e3adc16 --- /dev/null +++ b/frontend/shared/ui-components/src/components/LeaderboardTable.tsx @@ -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 = (props) => { + const maxRows = () => Math.max(1, props.maxRows ?? 10) + + return ( + }> + 0} fallback={{props.emptyMessage ?? 'Aucun score pour le moment.'}}> + + + + 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)} + + )} + + +
+
+
+ ) +} + +export default LeaderboardTable diff --git a/frontend/shared/ui-components/src/components/ResultsCard.test.tsx b/frontend/shared/ui-components/src/components/ResultsCard.test.tsx new file mode 100644 index 0000000..fe46202 --- /dev/null +++ b/frontend/shared/ui-components/src/components/ResultsCard.test.tsx @@ -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(() => ( + + )) + + 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(() => ( + + )) + + expect(screen.getByText('Aucune partie terminée pour le moment.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Démarrer une partie' })).toBeTruthy() + }) +}) diff --git a/frontend/shared/ui-components/src/components/ResultsCard.tsx b/frontend/shared/ui-components/src/components/ResultsCard.tsx new file mode 100644 index 0000000..5cc73c7 --- /dev/null +++ b/frontend/shared/ui-components/src/components/ResultsCard.tsx @@ -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 = (props) => { + return ( + + + + + {props.title ?? 'Résultats'} + + Aucune partie terminée pour le moment. + + + } + > + {(last) => ( + + + {props.title ?? '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'} + + + + + + + + )} + + + + ) +} + +export default ResultsCard diff --git a/frontend/shared/ui-components/src/components/ScoreDisplay.tsx b/frontend/shared/ui-components/src/components/ScoreDisplay.tsx new file mode 100644 index 0000000..337e41c --- /dev/null +++ b/frontend/shared/ui-components/src/components/ScoreDisplay.tsx @@ -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 +} + +export default ScoreDisplay diff --git a/frontend/shared/ui-components/src/components/ThemeBadge.tsx b/frontend/shared/ui-components/src/components/ThemeBadge.tsx new file mode 100644 index 0000000..a662b00 --- /dev/null +++ b/frontend/shared/ui-components/src/components/ThemeBadge.tsx @@ -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 +} + +export default ThemeBadge diff --git a/frontend/shared/ui-components/src/components/Timer.tsx b/frontend/shared/ui-components/src/components/Timer.tsx new file mode 100644 index 0000000..77fe806 --- /dev/null +++ b/frontend/shared/ui-components/src/components/Timer.tsx @@ -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 +} + +const Timer: Component = (props) => { + const mergedWarningText = () => ({ ...DEFAULT_TIMER_WARNING_TEXT, ...(props.warningText ?? {}) }) + const warning = () => getTimerWarning(props.remainingMs, mergedWarningText()) + + return ( + + + + + {warning()} + + + + ) +} + +export default Timer diff --git a/frontend/shared/ui-components/src/index.ts b/frontend/shared/ui-components/src/index.ts index 43c5a79..68fbde7 100644 --- a/frontend/shared/ui-components/src/index.ts +++ b/frontend/shared/ui-components/src/index.ts @@ -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' - diff --git a/frontend/shared/ui-components/src/types.ts b/frontend/shared/ui-components/src/types.ts new file mode 100644 index 0000000..3cc3aba --- /dev/null +++ b/frontend/shared/ui-components/src/types.ts @@ -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 +} diff --git a/frontend/apps/web/src/ui/Timer.test.ts b/frontend/shared/ui-components/src/utils/timer.test.ts similarity index 50% rename from frontend/apps/web/src/ui/Timer.test.ts rename to frontend/shared/ui-components/src/utils/timer.test.ts index 12eb273..3985e9a 100644 --- a/frontend/apps/web/src/ui/Timer.test.ts +++ b/frontend/shared/ui-components/src/utils/timer.test.ts @@ -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() }) }) diff --git a/frontend/shared/ui-components/src/utils/timer.ts b/frontend/shared/ui-components/src/utils/timer.ts new file mode 100644 index 0000000..a99ebf6 --- /dev/null +++ b/frontend/shared/ui-components/src/utils/timer.ts @@ -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' +} diff --git a/frontend/shared/ui-components/vitest.config.ts b/frontend/shared/ui-components/vitest.config.ts new file mode 100644 index 0000000..468fa38 --- /dev/null +++ b/frontend/shared/ui-components/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' +import solid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [solid()], + test: { + environment: 'jsdom', + }, +}) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9dccb72..86376e8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"