feat(simulations): autosave 30s + localStorage + reprise contenu (FTD-21)

This commit is contained in:
Hermann_Kitio 2026-04-21 03:57:10 +03:00
parent d395a04193
commit 549e5f698f
4 changed files with 244 additions and 1 deletions

View file

@ -21,6 +21,8 @@ import type { ApiError } from '@/shared/types/api'
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
import { useTimer } from '../hooks/useTimer'
import { useIdees } from '../hooks/useIdees'
import { useAutosave } from '../hooks/useAutosave'
import type { SimulationStep } from '../state/SimulationFlowProvider'
import { SujetDisplay } from './SujetDisplay'
import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
import { TimerDisplay } from './TimerDisplay'
@ -28,6 +30,7 @@ import { WordCountBar } from './WordCountBar'
import { IdeesSuggestions } from './IdeesSuggestions'
const MIN_WORDS_IDEES = 30
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
const textSchema = z.object({
texte: z
@ -55,6 +58,9 @@ interface Props {
tache: Tache
sujet: SujetData | null
plan: Plan
simulationId: string
initialContenu?: string
step: SimulationStep
isSubmitting: boolean
error: ApiError | null
onSubmit: (texte: string) => void
@ -66,6 +72,9 @@ export function SimulationForm({
tache,
sujet,
plan,
simulationId,
initialContenu,
step,
isSubmitting,
error,
onSubmit,
@ -74,7 +83,7 @@ export function SimulationForm({
}: Props) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const hasAutoSubmittedRef = useRef(false)
const [texte, setTexte] = useState('')
const [texte, setTexte] = useState(() => initialContenu ?? '')
const [fieldError, setFieldError] = useState<string | null>(null)
const [isIdeesOpen, setIsIdeesOpen] = useState(false)
@ -84,6 +93,21 @@ export function SimulationForm({
const timer = useTimer(config.dureeMinutes, !isSubmitting)
const idees = useIdees()
const autosave = useAutosave(simulationId, texte, !isSubmitting)
// FTD-21 — marquer la simulation en cours pour le resume au refresh.
useEffect(() => {
if (simulationId) {
localStorage.setItem(LS_SIMULATION_ID_KEY, simulationId)
}
}, [simulationId])
// FTD-21 — nettoyer dès que la correction est terminée.
useEffect(() => {
if (step === 'done') {
localStorage.removeItem(LS_SIMULATION_ID_KEY)
}
}, [step])
const tipsAllowed = hasAccess(plan, 'tips')
const ideesDisabled =
@ -281,6 +305,15 @@ export function SimulationForm({
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
/>
<WordCountBar count={wordCount} config={config} />
{autosave.savedAt && !fieldError && (
<p className="text-xs text-ink-4" aria-live="polite">
Sauvegardé à{' '}
{autosave.savedAt.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
)}
{fieldError && (
<p id="texte-error" className="text-sm text-danger">
{fieldError}