diff --git a/src/features/simulations/components/SimulationForm.tsx b/src/features/simulations/components/SimulationForm.tsx index 6fee654..6c35084 100644 --- a/src/features/simulations/components/SimulationForm.tsx +++ b/src/features/simulations/components/SimulationForm.tsx @@ -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(null) const hasAutoSubmittedRef = useRef(false) - const [texte, setTexte] = useState('') + const [texte, setTexte] = useState(() => initialContenu ?? '') const [fieldError, setFieldError] = useState(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" /> + {autosave.savedAt && !fieldError && ( +

+ Sauvegardé à{' '} + {autosave.savedAt.toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + })} +

+ )} {fieldError && (

{fieldError} diff --git a/src/features/simulations/hooks/__tests__/useAutosave.test.ts b/src/features/simulations/hooks/__tests__/useAutosave.test.ts new file mode 100644 index 0000000..39a8fb9 --- /dev/null +++ b/src/features/simulations/hooks/__tests__/useAutosave.test.ts @@ -0,0 +1,135 @@ +/** + * Tests du hook useAutosave — FTD-21. + * + * Valide : + * - debounce 30 s (pas de save avant, save après) + * - flush immédiat sur `beforeunload` + * - cleanup du listener au unmount + * - enabled=false → aucun appel + */ + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/entities/production/api', () => ({ + autosaveContenu: vi.fn().mockResolvedValue(undefined), +})) + +import { autosaveContenu } from '@/entities/production/api' +import { useAutosave } from '../useAutosave' + +const mocked = vi.mocked(autosaveContenu) + +describe('useAutosave', () => { + beforeEach(() => { + vi.useFakeTimers() + mocked.mockClear() + mocked.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('debounce 30 s : pas d\'appel avant, appel après', async () => { + const { rerender } = renderHook( + ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true), + { initialProps: { contenu: '' } }, + ) + + rerender({ contenu: 'hello world' }) + + // Avant 30 s : aucun appel + await act(async () => { + await vi.advanceTimersByTimeAsync(29_000) + }) + expect(mocked).not.toHaveBeenCalled() + + // Après 30 s : save + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect(mocked).toHaveBeenCalledTimes(1) + expect(mocked).toHaveBeenCalledWith('sim-1', 'hello world') + }) + + it('flush immédiat sur beforeunload', async () => { + const { rerender } = renderHook( + ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true), + { initialProps: { contenu: '' } }, + ) + + rerender({ contenu: 'texte à sauvegarder' }) + + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(mocked).toHaveBeenCalledTimes(1) + expect(mocked).toHaveBeenCalledWith('sim-1', 'texte à sauvegarder') + }) + + it('cleanup : unmount retire le listener beforeunload', async () => { + const { unmount, rerender } = renderHook( + ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true), + { initialProps: { contenu: '' } }, + ) + rerender({ contenu: 'texte' }) + + unmount() + + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(mocked).not.toHaveBeenCalled() + }) + + it('enabled=false : aucun appel, même après 30 s', async () => { + const { rerender } = renderHook( + ({ contenu, enabled }: { contenu: string; enabled: boolean }) => + useAutosave('sim-1', contenu, enabled), + { initialProps: { contenu: 'texte', enabled: false } }, + ) + rerender({ contenu: 'texte modifié', enabled: false }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(31_000) + }) + + expect(mocked).not.toHaveBeenCalled() + }) + + it('simulationId null : aucun appel', async () => { + renderHook(() => useAutosave(null, 'texte', true)) + + await act(async () => { + await vi.advanceTimersByTimeAsync(31_000) + }) + + expect(mocked).not.toHaveBeenCalled() + }) + + it('dédoublonnage : pas de second appel si le contenu n\'a pas changé', async () => { + const { rerender } = renderHook( + ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true), + { initialProps: { contenu: '' } }, + ) + + rerender({ contenu: 'identique' }) + await act(async () => { + await vi.advanceTimersByTimeAsync(31_000) + }) + expect(mocked).toHaveBeenCalledTimes(1) + + // Rerender avec même contenu → debounce ne repart pas (le contenu ref ne change pas côté React) + // Simule une édition puis retour au texte initial (déjà sauvegardé) + rerender({ contenu: 'identique' }) + await act(async () => { + await vi.advanceTimersByTimeAsync(31_000) + }) + expect(mocked).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/simulations/hooks/useAutosave.ts b/src/features/simulations/hooks/useAutosave.ts new file mode 100644 index 0000000..4e3d9bd --- /dev/null +++ b/src/features/simulations/hooks/useAutosave.ts @@ -0,0 +1,72 @@ +/** + * FTD-21 — autosave du contenu d'une simulation en cours. + * + * Debounce 30 s sur chaque changement de `contenu`. Flush immédiat au + * `beforeunload` (best-effort : la promesse peut ne pas aboutir si la page + * se ferme avant la réponse — le texte reste en `localStorage` côté texte + * utilisateur + state parent). + * + * Dédoublonnage : aucun appel réseau si le contenu n'a pas changé depuis + * le dernier save réussi. + * + * Règle H : pas de logique métier — wrap autour de `entities/production/api.autosaveContenu`. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { autosaveContenu } from '@/entities/production/api' + +const DEBOUNCE_MS = 30_000 + +export interface UseAutosaveResult { + savedAt: Date | null + isSaving: boolean +} + +export function useAutosave( + simulationId: string | null, + contenu: string, + enabled: boolean, +): UseAutosaveResult { + const [savedAt, setSavedAt] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const lastSavedContenuRef = useRef(null) + + const latestRef = useRef({ simulationId, contenu, enabled }) + latestRef.current = { simulationId, contenu, enabled } + + const flush = useCallback(async () => { + const { simulationId, contenu, enabled } = latestRef.current + if (!enabled || !simulationId || !contenu) return + if (lastSavedContenuRef.current === contenu) return + + setIsSaving(true) + try { + await autosaveContenu(simulationId, contenu) + lastSavedContenuRef.current = contenu + setSavedAt(new Date()) + } catch { + // best-effort — pas d'UI d'erreur, le texte reste en mémoire côté client + } finally { + setIsSaving(false) + } + }, []) + + useEffect(() => { + if (!enabled || !simulationId || !contenu) return + if (lastSavedContenuRef.current === contenu) return + const timer = setTimeout(() => { + void flush() + }, DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [simulationId, contenu, enabled, flush]) + + useEffect(() => { + const handler = () => { + void flush() + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [flush]) + + return { savedAt, isSaving } +} diff --git a/src/features/simulations/pages/SimulationPage.tsx b/src/features/simulations/pages/SimulationPage.tsx index 9895e1b..b36e863 100644 --- a/src/features/simulations/pages/SimulationPage.tsx +++ b/src/features/simulations/pages/SimulationPage.tsx @@ -105,6 +105,9 @@ export function SimulationPage() { tache={production.tache} sujet={sujet} plan={planData.plan} + simulationId={production.id} + initialContenu={production.contenu} + step={step} isSubmitting={isCorrecting} error={correctError} onSubmit={submitText}