feat(simulations): autosave 30s + localStorage + reprise contenu (FTD-21)
This commit is contained in:
parent
d395a04193
commit
549e5f698f
4 changed files with 244 additions and 1 deletions
|
|
@ -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}
|
||||
|
|
|
|||
135
src/features/simulations/hooks/__tests__/useAutosave.test.ts
Normal file
135
src/features/simulations/hooks/__tests__/useAutosave.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
72
src/features/simulations/hooks/useAutosave.ts
Normal file
72
src/features/simulations/hooks/useAutosave.ts
Normal file
|
|
@ -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<Date | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const lastSavedContenuRef = useRef<string | null>(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 }
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue