Sprint 6c — Frontend T2 Live UI + state machine + integration

feat(t2-live): state machine pure 9 states x 8 events (resolves FTD-09)
feat(t2-live): useT2LiveSession WS orchestrator + audio hooks + close codes
feat(t2-live): T2SujetsPage, T2PreparationPage (2min timer + notes + ideas),
  T2DialoguePage (3:30 dialogue + terminal screen with WAV download)
feat(t2-live): T2LiveContext provider for sujet sharing between pages
fix(TaskSelector): unlock EO_T2_LIVE card via hasAccess (resolves FTD-33)
chore: Tache type + labels + config extended with EO_T2_LIVE
test: 21 t2-machine tests — 259/259 green (+21)
This commit is contained in:
Hermann_Kitio 2026-04-26 20:28:35 +03:00
parent 7862f7c9f3
commit 1d95166611
17 changed files with 1229 additions and 33 deletions

View file

@ -0,0 +1,212 @@
/**
* Page /simulation/eo/t2/dialogue phase de dialogue live (Sprint 6c).
*
* Démarre la session WS au mount, pilote l'UI selon l'état machine T2, affiche
* le timer 3:30 et l'indicateur d'état. À la fin (REPORT_READY), affiche un
* écran terminal avec deux boutons : "Télécharger l'audio" et "Voir le rapport".
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Download, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import { useT2LiveContext } from '../state/T2LiveContext'
import { useT2LiveSession } from '../hooks/useT2LiveSession'
const DIALOGUE_SECONDS = 210 // 3:30
function formatMmSs(totalSeconds: number): string {
const remaining = Math.max(0, totalSeconds)
const m = Math.floor(remaining / 60)
const s = remaining % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function T2DialoguePage() {
const navigate = useNavigate()
const { sujet, reset: resetContext } = useT2LiveContext()
const [autoStarted, setAutoStarted] = useState(false)
const session = useT2LiveSession({
sujetId: sujet?.id ?? '',
})
// Garde-fou : pas de sujet → retour à la sélection.
useEffect(() => {
if (!sujet) navigate('/simulation/eo/t2', { replace: true })
}, [sujet, navigate])
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
useEffect(() => {
if (!sujet) return
if (autoStarted) return
setAutoStarted(true)
void session.startDialogue()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sujet, autoStarted])
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
const stateLabel = (() => {
switch (session.state) {
case 'idle':
case 'connecting':
return 'Connexion à lexaminateur…'
case 'ready':
return 'À vous — prenez la parole.'
case 'speaking':
return 'Lexaminateur écoute…'
case 'listening':
return 'Lexaminateur répond…'
case 'processing':
return 'Évaluation en cours…'
case 'ended':
return 'Session terminée.'
case 'error':
return 'Erreur.'
case 'preparing':
return 'Préparation…'
}
})()
function handleDownload() {
const blob = session.recording.exportWAV()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `expria-t2-${new Date().toISOString().slice(0, 10)}.wav`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function handleViewReport() {
if (!session.simulationId) return
resetContext()
navigate(`/rapport/${session.simulationId}`)
}
function handleBackToSujets() {
resetContext()
navigate('/simulation/eo/t2')
}
if (!sujet) return null
// ── État terminal : rapport prêt ─────────────────────────────────────────
if (session.state === 'ended') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Session terminée</h2>
<Card variant="default" className="space-y-3 p-5">
<p className="text-sm text-ink-primary">
Votre dialogue a é évalué. Vous pouvez télécharger l'enregistrement audio avant de
consulter le rapport.
</p>
<p className="text-xs text-ink-secondary">
Durée enregistrée :{' '}
<span className="font-semibold tabular-nums text-ink-primary">
{session.recording.durationSeconds.toFixed(1)} s
</span>
</p>
</Card>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="secondary"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
disabled={session.recording.durationSeconds === 0}
>
Télécharger l'audio
</Button>
<Button
variant="primary"
icon={<FileText className="size-4" aria-hidden="true" />}
onClick={handleViewReport}
disabled={!session.simulationId}
>
Voir le rapport
</Button>
</div>
</main>
</div>
)
}
// ── État erreur ──────────────────────────────────────────────────────────
if (session.state === 'error') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Erreur</h2>
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
>
{session.errorMessage ?? 'Une erreur est survenue.'}
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleBackToSujets}>
Retour aux sujets
</Button>
</div>
</main>
</div>
)
}
// ── État dialogue actif ──────────────────────────────────────────────────
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink-primary">Dialogue en cours</h2>
<span
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
aria-label="Temps restant de dialogue"
>
{formatMmSs(remaining)}
</span>
</div>
{session.warning && (
<div
role="status"
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
>
30 secondes restantes.
</div>
)}
<Card variant="default" className="space-y-4 p-5">
<div className="flex items-center gap-3">
{session.state === 'processing' ? (
<Loader2 className="size-5 animate-spin text-brand-text" aria-hidden="true" />
) : (
<Mic
className={
session.state === 'speaking' ? 'size-5 text-success' : 'size-5 text-ink-secondary'
}
aria-hidden="true"
/>
)}
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
</div>
<p className="text-xs text-ink-secondary">{sujet.consigne}</p>
</Card>
<div className="flex justify-center">
<Button
variant="secondary"
onClick={() => session.endDialogue()}
disabled={session.state === 'processing'}
>
Terminer le dialogue
</Button>
</div>
</main>
</div>
)
}

View file

@ -0,0 +1,210 @@
/**
* Page /simulation/eo/t2/preparation phase de préparation T2 Live (Sprint 6c).
*
* - Timer 2 min visible (countdown).
* - Consigne + contexte du sujet affichés.
* - Zone de notes locale (état interne, non sauvegardée).
* - Bouton "Suggestions d'idées" réutilise useIdees (POST /sujets/idees).
* Pas de seuil MIN_WORDS : on passe la consigne du sujet comme contenu_partiel.
* - Bouton "Je suis prêt" navigation vers /dialogue avant la fin du timer.
* - Auto-navigation à 0:00.
* - Pré-warm de la permission micro pour éviter le délai au début du dialogue.
*/
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Sparkles, Loader2 } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import { useIdees } from '@/features/simulations/hooks/useIdees'
import { IdeesSuggestions } from '@/features/simulations/components/IdeesSuggestions'
import { useT2LiveContext } from '../state/T2LiveContext'
const PREPARATION_SECONDS = 120
function formatMmSs(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60)
const s = totalSeconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function T2PreparationPage() {
const navigate = useNavigate()
const { sujet } = useT2LiveContext()
const [secondsLeft, setSecondsLeft] = useState(PREPARATION_SECONDS)
const [notes, setNotes] = useState('')
const [showIdees, setShowIdees] = useState(false)
const [micWarmed, setMicWarmed] = useState<boolean | null>(null)
const expiredRef = useRef(false)
const idees = useIdees()
// Garde-fou : pas de sujet → retour à la sélection.
useEffect(() => {
if (!sujet) navigate('/simulation/eo/t2', { replace: true })
}, [sujet, navigate])
// Pré-warm permission micro (le hook useAudioCapture le fera de toute façon
// au start, mais on demande la permission ici pour éviter la latence à 0:00).
useEffect(() => {
let cancelled = false
let stream: MediaStream | null = null
void navigator.mediaDevices
.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
})
.then((s) => {
stream = s
if (cancelled) {
s.getTracks().forEach((t) => t.stop())
return
}
// Permission acquise — on relâche le stream pour ne pas bloquer le micro.
s.getTracks().forEach((t) => t.stop())
setMicWarmed(true)
})
.catch(() => {
if (!cancelled) setMicWarmed(false)
})
return () => {
cancelled = true
if (stream) stream.getTracks().forEach((t) => t.stop())
}
}, [])
// Timer countdown 2 min.
useEffect(() => {
const id = setInterval(() => {
setSecondsLeft((s) => {
if (s <= 1) {
clearInterval(id)
if (!expiredRef.current) {
expiredRef.current = true
// Auto-navigation à la fin de la prépa.
queueMicrotask(() => navigate('/simulation/eo/t2/dialogue'))
}
return 0
}
return s - 1
})
}, 1000)
return () => clearInterval(id)
}, [navigate])
function handleReady() {
navigate('/simulation/eo/t2/dialogue')
}
function handleIdees() {
if (!sujet) return
// Pas de seuil MIN_WORDS pour T2 prép : on passe la consigne + contexte
// comme contenu_partiel pour respecter la validation backend (≥ 30 mots).
// Le mécanisme DeepSeek génère des idées de questions à poser.
const consigne = sujet.consigne ?? 'Tâche 2 — interaction de service'
const contexte = (sujet as { contexte?: string | null }).contexte ?? consigne
setShowIdees(true)
idees.fetchIdees({ consigne, contenu: contexte })
}
if (!sujet) return null
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink-primary">Préparation Tâche 2 Live</h2>
<span
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
aria-label="Temps restant de préparation"
>
{formatMmSs(secondsLeft)}
</span>
</div>
<Card variant="default" className="space-y-3 p-5">
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
Consigne
</p>
<p className="text-sm text-ink-primary">{sujet.consigne}</p>
{(sujet as { contexte?: string | null }).contexte && (
<>
<p className="pt-2 text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
Contexte
</p>
<p className="text-sm text-ink-secondary">
{(sujet as { contexte?: string | null }).contexte}
</p>
</>
)}
</Card>
<Card variant="default" className="p-4">
<p className="text-sm text-ink-secondary">
<strong className="text-ink-primary">Comment ça se passe :</strong> c'est à vous de
prendre la parole en premier pour initier la conversation, comme à l'examen réel.
L'examinateur IA attend que vous lui posiez vos questions.
</p>
</Card>
<Card variant="default" className="space-y-2 p-4">
<label
htmlFor="t2-notes"
className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary"
>
Vos notes (locales non sauvegardées)
</label>
<textarea
id="t2-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={6}
placeholder="Notez vos questions, vos points à aborder…"
className="w-full resize-y rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:outline-none focus:shadow-focus"
/>
</Card>
{micWarmed === false && (
<div
role="alert"
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
>
Accès au micro refusé. Activez-le dans les paramètres du navigateur avant de démarrer le
dialogue.
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
variant="secondary"
icon={
idees.isLoading ? (
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
) : (
<Sparkles className="size-4" aria-hidden="true" />
)
}
onClick={handleIdees}
disabled={idees.isLoading}
>
Suggestions d'idées
</Button>
<Button variant="primary" onClick={handleReady}>
Je suis prêt démarrer le dialogue
</Button>
</div>
<IdeesSuggestions
idees={idees.idees}
isLoading={idees.isLoading}
error={idees.error}
isOpen={showIdees}
onClose={() => {
setShowIdees(false)
idees.reset()
}}
/>
</main>
</div>
)
}

View file

@ -0,0 +1,109 @@
/**
* Page /simulation/eo/t2 sélection d'un sujet T2 EO Live (Sprint 6c).
*
* Pattern emprunté à SujetsEOPage : grille de sujets + sujet aléatoire.
* Différence clé : le sujet est stocké dans T2LiveContext (pas SimulationFlowProvider)
* la production sera créée par le backend en fin de session, pas au clic.
*/
import { useNavigate } from 'react-router-dom'
import { Shuffle } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { useSujets } from '@/features/simulations/hooks/useSujets'
import { SujetCard } from '@/features/simulations/components/SujetCard'
import type { SujetData } from '@/entities/production/types'
import { useT2LiveContext } from '../state/T2LiveContext'
function SujetsSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-lg bg-surface" />
))}
</div>
)
}
export function T2SujetsPage() {
const navigate = useNavigate()
const { setSujet } = useT2LiveContext()
const { data: sujets, isLoading, isError, refetch } = useSujets('EO_T2_LIVE', true)
function handleSelect(sujet: SujetData) {
setSujet(sujet)
navigate('/simulation/eo/t2/preparation')
}
function handleRandom() {
if (!sujets || sujets.length === 0) return
const pick = sujets[Math.floor(Math.random() * sujets.length)]
if (pick) handleSelect(pick)
}
const hasSujets = (sujets?.length ?? 0) > 0
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-4xl px-4 py-6">
<div className="mb-4 flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/simulation/eo')}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
>
Retour
</button>
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
Choisir un sujet Tâche 2 Live
</h2>
</div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-ink-secondary">
{isLoading
? 'Chargement des sujets…'
: hasSujets
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
: 'Aucun sujet disponible pour cette tâche.'}
</p>
<Button
variant="secondary"
size="sm"
icon={<Shuffle className="size-4" aria-hidden="true" />}
onClick={handleRandom}
disabled={!hasSujets}
>
Sujet aléatoire
</Button>
</div>
{isError && (
<div
role="alert"
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
Impossible de charger les sujets.{' '}
<button
type="button"
onClick={() => refetch()}
className="underline underline-offset-2"
>
Réessayer
</button>
</div>
)}
{isLoading ? (
<SujetsSkeleton />
) : hasSujets ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sujets!.map((sujet) => (
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
))}
</div>
) : null}
</main>
</div>
)
}