From 29bd50acab33b489bf525f49482b5a399213d7fb Mon Sep 17 00:00:00 2001 From: poignatov Date: Mon, 9 Feb 2026 16:07:36 +0300 Subject: [PATCH] =?UTF-8?q?5.0.9:=20=D0=9E=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-web/package.json | 2 +- play-life-web/src/components/TestWords.jsx | 200 ++++++++++++--------- 3 files changed, 121 insertions(+), 83 deletions(-) diff --git a/VERSION b/VERSION index 51e67ba..bb09d45 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.0.8 +5.0.9 diff --git a/play-life-web/package.json b/play-life-web/package.json index 3f17b8d..1838d75 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "5.0.8", + "version": "5.0.9", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TestWords.jsx b/play-life-web/src/components/TestWords.jsx index b197ac0..35597b5 100644 --- a/play-life-web/src/components/TestWords.jsx +++ b/play-life-web/src/components/TestWords.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import { useAuth } from './auth/AuthContext' import LoadingError from './LoadingError' import './TestWords.css' @@ -26,11 +26,14 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC const [error, setError] = useState('') const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов + const [isClosing, setIsClosing] = useState(false) // Ожидание завершения сохранения при нажатии «Закончить» const isFinishingRef = useRef(false) const wordStatsRef = useRef({}) const processingRef = useRef(false) const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition + const saveProgressPromiseRef = useRef(null) // Промис сохранения прогресса (null = ещё не запущено) + const savePayloadRef = useRef(null) // Данные для сохранения при нажатии «Закончить» (если сохранение ещё не запускали) // Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами // excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка) @@ -221,6 +224,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста isFinishingRef.current = false processingRef.current = false + saveProgressPromiseRef.current = null + savePayloadRef.current = null setLoading(true) const loadWords = async () => { @@ -307,89 +312,80 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC }) } - // Завершение теста - const finishTest = async () => { + // Завершение теста: показываем результаты сразу, сохранение на бэкенде идёт в фоне (промис в ref для ожидания в handleFinish) + const finishTest = () => { if (isFinishingRef.current) return isFinishingRef.current = true - - // Сразу показываем экран результатов, чтобы предотвратить показ новых карточек - setShowResults(true) - - // Отправляем статистику на бэкенд - try { - // Получаем актуальные данные из состояния - const currentStats = wordStatsRef.current - - // Отправляем все слова, которые были в тесте, с их текущими значениями - // Бэкенд сам обновит только измененные поля - const updates = words.map(word => { - const stats = currentStats[word.id] || { - success: word.success || 0, - failure: word.failure || 0, - lastSuccessAt: word.last_success_at || null, - lastFailureAt: word.last_failure_at || null - } - return { - id: word.id, - success: stats.success || 0, - failure: stats.failure || 0, - last_success_at: stats.lastSuccessAt || null, - last_failure_at: stats.lastFailureAt || null - } - }) - if (updates.length === 0) { - console.log('No words to send - empty test') - return + const currentStats = wordStatsRef.current + const updates = words.map(word => { + const stats = currentStats[word.id] || { + success: word.success || 0, + failure: word.failure || 0, + lastSuccessAt: word.last_success_at || null, + lastFailureAt: word.last_failure_at || null } - - const requestBody = { words: updates } - if (configId !== null) { - requestBody.config_id = configId + return { + id: word.id, + success: stats.success || 0, + failure: stats.failure || 0, + last_success_at: stats.lastSuccessAt || null, + last_failure_at: stats.lastFailureAt || null } + }) - console.log('Sending test progress to backend:', { - wordsCount: updates.length, - configId: configId, - requestBody - }) - - const response = await authFetch(`${API_URL}/test/progress`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Server responded with status ${response.status}: ${errorText}`) - } - - const responseData = await response.json().catch(() => ({})) - console.log('Test progress saved successfully:', responseData) - - // Если есть taskId, выполняем задачу - if (taskId) { - try { - const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - - if (completeResponse.ok) { - console.log('Task completed successfully') - } else { - console.error('Failed to complete task:', await completeResponse.text()) - } - } catch (taskErr) { - console.error('Failed to complete task:', taskErr) - } - } - } catch (err) { - console.error('Failed to save progress:', err) - // Можно показать уведомление пользователю, но не блокируем показ результатов + if (updates.length === 0) { + saveProgressPromiseRef.current = Promise.resolve() + savePayloadRef.current = null + setShowResults(true) + return } + + const requestBody = { words: updates } + if (configId !== null) { + requestBody.config_id = configId + } + + savePayloadRef.current = { requestBody, configId, taskId } + + const doSave = async () => { + try { + const response = await authFetch(`${API_URL}/test/progress`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Server responded with status ${response.status}: ${errorText}`) + } + + await response.json().catch(() => ({})) + + if (taskId) { + try { + const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + if (!completeResponse.ok) { + console.error('Failed to complete task:', await completeResponse.text()) + } + } catch (taskErr) { + console.error('Failed to complete task:', taskErr) + } + } + } catch (err) { + console.error('Failed to save progress:', err) + } + } + + const promise = doSave() + saveProgressPromiseRef.current = promise + setShowResults(true) } // Берём карточку из пула (getAndDelete) и показываем её @@ -566,8 +562,46 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC showNextCard() } - const handleFinish = () => { - onNavigate?.('tasks') + const runSaveFromPayload = useCallback(async (payload) => { + const { requestBody, taskId: payloadTaskId } = payload + const progressRes = await authFetch(`${API_URL}/test/progress`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + if (!progressRes.ok) { + const text = await progressRes.text() + throw new Error(`Progress save failed: ${progressRes.status} ${text}`) + } + if (payloadTaskId) { + const completeRes = await authFetch(`${API_URL}/tasks/${payloadTaskId}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + if (!completeRes.ok) { + const text = await completeRes.text() + throw new Error(`Task complete failed: ${completeRes.status} ${text}`) + } + } + }, [authFetch]) + + const handleFinish = async () => { + if (isClosing) return + setIsClosing(true) + try { + let promise = saveProgressPromiseRef.current + if (promise == null && savePayloadRef.current) { + promise = runSaveFromPayload(savePayloadRef.current) + saveProgressPromiseRef.current = promise + } + await (promise ?? Promise.resolve()) + onNavigate?.('tasks') + } catch (err) { + console.error('Failed to save before close:', err) + } finally { + setIsClosing(false) + } } const getRandomSide = (word) => { @@ -630,8 +664,12 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC })}
-