2026-02-09 16:07:36 +03:00
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
2026-02-08 17:01:36 +03:00
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
|
|
|
|
|
import LoadingError from './LoadingError'
|
|
|
|
|
|
import './TestWords.css'
|
|
|
|
|
|
import './Integrations.css'
|
|
|
|
|
|
|
|
|
|
|
|
const API_URL = '/api'
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_TEST_WORD_COUNT = 10
|
|
|
|
|
|
|
|
|
|
|
|
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
|
|
|
|
|
const configId = initialConfigId || null
|
|
|
|
|
|
const maxCards = initialMaxCards || null
|
|
|
|
|
|
const taskId = initialTaskId || null
|
|
|
|
|
|
|
|
|
|
|
|
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
|
|
|
|
|
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
|
|
|
|
|
const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула)
|
|
|
|
|
|
const [flippedCards, setFlippedCards] = useState(new Set())
|
|
|
|
|
|
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
|
|
|
|
|
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
|
|
|
|
|
const [totalAnswers, setTotalAnswers] = useState(0) // Кол-во полученных ответов
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
|
|
|
|
|
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
2026-02-09 16:07:36 +03:00
|
|
|
|
const [isClosing, setIsClosing] = useState(false) // Ожидание завершения сохранения при нажатии «Закончить»
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
|
|
|
|
|
const isFinishingRef = useRef(false)
|
|
|
|
|
|
const wordStatsRef = useRef({})
|
|
|
|
|
|
const processingRef = useRef(false)
|
|
|
|
|
|
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
|
2026-02-09 16:07:36 +03:00
|
|
|
|
const saveProgressPromiseRef = useRef(null) // Промис сохранения прогресса (null = ещё не запущено)
|
|
|
|
|
|
const savePayloadRef = useRef(null) // Данные для сохранения при нажатии «Закончить» (если сохранение ещё не запускали)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
|
|
|
|
|
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
|
|
|
|
|
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
|
|
|
|
|
const redistributeWordsEvenly = (currentPool, allWords, excludeFirstWordId = null) => {
|
|
|
|
|
|
if (currentPool.length === 0 || allWords.length === 0) {
|
|
|
|
|
|
return currentPool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Подсчитываем, сколько раз каждое слово встречается в текущем пуле
|
|
|
|
|
|
const wordCounts = {}
|
|
|
|
|
|
currentPool.forEach(word => {
|
|
|
|
|
|
wordCounts[word.id] = (wordCounts[word.id] || 0) + 1
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Получаем список уникальных слов, которые есть в пуле
|
|
|
|
|
|
const uniqueWordIds = Object.keys(wordCounts).map(id => parseInt(id))
|
|
|
|
|
|
const uniqueWords = allWords.filter(word => uniqueWordIds.includes(word.id))
|
|
|
|
|
|
|
|
|
|
|
|
if (uniqueWords.length === 0) {
|
|
|
|
|
|
return currentPool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Проверяем, есть ли в пуле слова, отличные от исключаемого
|
|
|
|
|
|
const hasOtherWords = uniqueWords.some(w => w.id !== excludeFirstWordId)
|
|
|
|
|
|
const effectiveExcludeId = hasOtherWords ? excludeFirstWordId : null
|
|
|
|
|
|
|
|
|
|
|
|
// Создаём массив всех экземпляров слов для распределения
|
|
|
|
|
|
const allInstances = []
|
|
|
|
|
|
for (const word of uniqueWords) {
|
|
|
|
|
|
const count = wordCounts[word.id]
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
|
allInstances.push({ ...word })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Перемешиваем экземпляры
|
|
|
|
|
|
for (let i = allInstances.length - 1; i > 0; i--) {
|
|
|
|
|
|
const j = Math.floor(Math.random() * (i + 1))
|
|
|
|
|
|
;[allInstances[i], allInstances[j]] = [allInstances[j], allInstances[i]]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Используем жадный алгоритм: на каждую позицию выбираем слово,
|
|
|
|
|
|
// которое максимально далеко от своего последнего появления
|
|
|
|
|
|
const totalSlots = currentPool.length
|
|
|
|
|
|
const newPool = new Array(totalSlots).fill(null)
|
|
|
|
|
|
const lastPosition = {} // Последняя позиция каждого слова
|
|
|
|
|
|
|
|
|
|
|
|
for (let pos = 0; pos < totalSlots; pos++) {
|
|
|
|
|
|
let bestWord = null
|
|
|
|
|
|
let bestWordIndex = -1
|
|
|
|
|
|
let bestDistance = -1
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < allInstances.length; i++) {
|
|
|
|
|
|
const word = allInstances[i]
|
|
|
|
|
|
|
|
|
|
|
|
// Для позиции 0: не выбираем исключаемое слово, если есть альтернативы
|
|
|
|
|
|
if (pos === 0 && word.id === effectiveExcludeId) {
|
|
|
|
|
|
// Проверяем, есть ли другие слова
|
|
|
|
|
|
const hasAlternative = allInstances.some(w => w.id !== effectiveExcludeId)
|
|
|
|
|
|
if (hasAlternative) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Вычисляем расстояние от последнего появления этого слова
|
|
|
|
|
|
const lastPos = lastPosition[word.id]
|
|
|
|
|
|
const distance = lastPos === undefined ? totalSlots : (pos - lastPos)
|
|
|
|
|
|
|
|
|
|
|
|
// Выбираем слово с максимальным расстоянием
|
|
|
|
|
|
if (distance > bestDistance) {
|
|
|
|
|
|
bestDistance = distance
|
|
|
|
|
|
bestWord = word
|
|
|
|
|
|
bestWordIndex = i
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (bestWord !== null) {
|
|
|
|
|
|
newPool[pos] = bestWord
|
|
|
|
|
|
lastPosition[bestWord.id] = pos
|
|
|
|
|
|
allInstances.splice(bestWordIndex, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем его с ближайшим другим
|
|
|
|
|
|
if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) {
|
|
|
|
|
|
for (let i = 1; i < newPool.length; i++) {
|
|
|
|
|
|
if (newPool[i] && newPool[i].id !== effectiveExcludeId) {
|
|
|
|
|
|
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Пост-обработка: исправляем последовательные дубликаты (одинаковые слова подряд)
|
|
|
|
|
|
let iterations = 0
|
|
|
|
|
|
const maxIterations = totalSlots * 2 // Предотвращаем бесконечный цикл
|
|
|
|
|
|
let hasConsecutiveDuplicates = true
|
|
|
|
|
|
|
|
|
|
|
|
while (hasConsecutiveDuplicates && iterations < maxIterations) {
|
|
|
|
|
|
hasConsecutiveDuplicates = false
|
|
|
|
|
|
iterations++
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < newPool.length - 1; i++) {
|
|
|
|
|
|
if (newPool[i] && newPool[i + 1] && newPool[i].id === newPool[i + 1].id) {
|
|
|
|
|
|
// Нашли последовательные дубликаты на позициях i и i+1
|
|
|
|
|
|
// Ищем слово для обмена (не то же самое и не соседнее с дубликатом после обмена)
|
|
|
|
|
|
let swapped = false
|
|
|
|
|
|
|
|
|
|
|
|
for (let j = i + 2; j < newPool.length && !swapped; j++) {
|
|
|
|
|
|
if (!newPool[j]) continue
|
|
|
|
|
|
|
|
|
|
|
|
// Проверяем, что слово на позиции j отличается от дубликата
|
|
|
|
|
|
if (newPool[j].id === newPool[i].id) continue
|
|
|
|
|
|
|
|
|
|
|
|
// Проверяем, что после обмена не создадим новые дубликаты
|
|
|
|
|
|
// Позиция j-1 (если существует) не должна иметь тот же id, что и newPool[i+1]
|
|
|
|
|
|
// Позиция j+1 (если существует) не должна иметь тот же id, что и newPool[i+1]
|
|
|
|
|
|
const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id
|
|
|
|
|
|
const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id
|
|
|
|
|
|
|
|
|
|
|
|
if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) {
|
|
|
|
|
|
// Меняем местами
|
|
|
|
|
|
;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]]
|
|
|
|
|
|
swapped = true
|
|
|
|
|
|
hasConsecutiveDuplicates = true // Нужна ещё одна итерация для проверки
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Если не нашли подходящую позицию справа, ищем слева
|
|
|
|
|
|
if (!swapped) {
|
|
|
|
|
|
for (let j = 0; j < i && !swapped; j++) {
|
|
|
|
|
|
if (!newPool[j]) continue
|
|
|
|
|
|
if (newPool[j].id === newPool[i].id) continue
|
|
|
|
|
|
|
|
|
|
|
|
// Для позиции 0: не меняем на исключаемое слово
|
|
|
|
|
|
if (j === 0 && newPool[i + 1].id === effectiveExcludeId) continue
|
|
|
|
|
|
|
|
|
|
|
|
const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id
|
|
|
|
|
|
const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id
|
|
|
|
|
|
|
|
|
|
|
|
if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) {
|
|
|
|
|
|
;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]]
|
|
|
|
|
|
swapped = true
|
|
|
|
|
|
hasConsecutiveDuplicates = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ещё раз проверяем позицию 0 после всех обменов
|
|
|
|
|
|
if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) {
|
|
|
|
|
|
for (let i = 1; i < newPool.length; i++) {
|
|
|
|
|
|
if (newPool[i] && newPool[i].id !== effectiveExcludeId) {
|
|
|
|
|
|
// Проверяем, не создаст ли обмен дубликат на позиции 1
|
|
|
|
|
|
if (i === 1 || (newPool[1] && newPool[1].id !== newPool[i].id)) {
|
|
|
|
|
|
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Заполняем null-позиции (не должно происходить, но на всякий случай)
|
|
|
|
|
|
for (let i = 0; i < newPool.length; i++) {
|
|
|
|
|
|
if (newPool[i] === null && currentPool[i]) {
|
|
|
|
|
|
newPool[i] = currentPool[i]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return newPool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка слов при монтировании
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setWords([])
|
|
|
|
|
|
setTestWords([])
|
|
|
|
|
|
setCurrentWord(null)
|
|
|
|
|
|
setFlippedCards(new Set())
|
|
|
|
|
|
setWordStats({})
|
|
|
|
|
|
wordStatsRef.current = {}
|
|
|
|
|
|
setCardsShown(0)
|
|
|
|
|
|
cardsShownRef.current = 0 // Сбрасываем синхронный счётчик
|
|
|
|
|
|
setTotalAnswers(0)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
setShowPreview(false) // Сбрасываем экран предпросмотра
|
|
|
|
|
|
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
|
|
|
|
|
isFinishingRef.current = false
|
|
|
|
|
|
processingRef.current = false
|
2026-02-09 16:07:36 +03:00
|
|
|
|
saveProgressPromiseRef.current = null
|
|
|
|
|
|
savePayloadRef.current = null
|
2026-02-08 17:01:36 +03:00
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
|
|
|
|
|
const loadWords = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (configId === null) {
|
|
|
|
|
|
throw new Error('config_id обязателен для запуска теста')
|
|
|
|
|
|
}
|
|
|
|
|
|
const url = `${API_URL}/test/words?config_id=${configId}`
|
|
|
|
|
|
const response = await authFetch(url)
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при загрузке слов')
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(data) || data.length === 0) {
|
|
|
|
|
|
throw new Error('Недостаточно слов для теста')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Инициализируем статистику из данных бэкенда
|
|
|
|
|
|
const stats = {}
|
|
|
|
|
|
data.forEach(word => {
|
|
|
|
|
|
stats[word.id] = {
|
|
|
|
|
|
success: word.success || 0,
|
|
|
|
|
|
failure: word.failure || 0,
|
|
|
|
|
|
lastSuccessAt: word.last_success_at || null,
|
|
|
|
|
|
lastFailureAt: word.last_failure_at || null
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
setWords(data)
|
|
|
|
|
|
|
|
|
|
|
|
// Формируем пул слов: каждое слово добавляется n раз, затем пул перемешивается
|
|
|
|
|
|
// n = max(1, floor(0.7 * maxCards / количество_слов))
|
|
|
|
|
|
const wordsCount = data.length
|
|
|
|
|
|
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
|
|
|
|
|
|
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
|
|
|
|
|
|
|
|
|
|
|
// Создаем пул, где каждое слово повторяется n раз
|
|
|
|
|
|
let wordPool = []
|
|
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
|
|
|
|
wordPool.push(...data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Равномерно распределяем слова в пуле
|
|
|
|
|
|
wordPool = redistributeWordsEvenly(wordPool, data)
|
|
|
|
|
|
|
|
|
|
|
|
setTestWords(wordPool)
|
|
|
|
|
|
setWordStats(stats)
|
|
|
|
|
|
wordStatsRef.current = stats
|
|
|
|
|
|
|
|
|
|
|
|
// Показываем экран предпросмотра
|
|
|
|
|
|
setShowPreview(true)
|
|
|
|
|
|
|
|
|
|
|
|
// Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста)
|
|
|
|
|
|
setCardsShown(0)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadWords()
|
|
|
|
|
|
}, [wordCount, configId])
|
|
|
|
|
|
|
|
|
|
|
|
// Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards
|
|
|
|
|
|
const getRightCounter = () => {
|
|
|
|
|
|
const total = testWords.length + cardsShown
|
|
|
|
|
|
if (maxCards !== null && maxCards > 0) {
|
|
|
|
|
|
return Math.min(total, maxCards)
|
|
|
|
|
|
}
|
|
|
|
|
|
return total
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCardFlip = (wordId) => {
|
|
|
|
|
|
setFlippedCards(prev => {
|
|
|
|
|
|
const newSet = new Set(prev)
|
|
|
|
|
|
if (newSet.has(wordId)) {
|
|
|
|
|
|
newSet.delete(wordId)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newSet.add(wordId)
|
|
|
|
|
|
}
|
|
|
|
|
|
return newSet
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 16:07:36 +03:00
|
|
|
|
// Завершение теста: показываем результаты сразу, сохранение на бэкенде идёт в фоне (промис в ref для ожидания в handleFinish)
|
|
|
|
|
|
const finishTest = () => {
|
2026-02-08 17:01:36 +03:00
|
|
|
|
if (isFinishingRef.current) return
|
|
|
|
|
|
isFinishingRef.current = true
|
|
|
|
|
|
|
2026-02-09 16:07:36 +03:00
|
|
|
|
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
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
2026-02-09 16:07:36 +03:00
|
|
|
|
return {
|
|
|
|
|
|
id: word.id,
|
|
|
|
|
|
success: stats.success || 0,
|
|
|
|
|
|
failure: stats.failure || 0,
|
|
|
|
|
|
last_success_at: stats.lastSuccessAt || null,
|
|
|
|
|
|
last_failure_at: stats.lastFailureAt || null
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
2026-02-09 16:07:36 +03:00
|
|
|
|
})
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
2026-02-09 16:07:36 +03:00
|
|
|
|
if (updates.length === 0) {
|
|
|
|
|
|
saveProgressPromiseRef.current = Promise.resolve()
|
|
|
|
|
|
savePayloadRef.current = null
|
|
|
|
|
|
setShowResults(true)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
2026-02-09 16:07:36 +03:00
|
|
|
|
const requestBody = { words: updates }
|
|
|
|
|
|
if (configId !== null) {
|
|
|
|
|
|
requestBody.config_id = configId
|
|
|
|
|
|
}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
2026-02-09 16:07:36 +03:00
|
|
|
|
savePayloadRef.current = { requestBody, configId, taskId }
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
2026-02-09 16:07:36 +03:00
|
|
|
|
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)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-09 16:07:36 +03:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to save progress:', err)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-09 16:07:36 +03:00
|
|
|
|
|
|
|
|
|
|
const promise = doSave()
|
|
|
|
|
|
saveProgressPromiseRef.current = promise
|
|
|
|
|
|
setShowResults(true)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Берём карточку из пула (getAndDelete) и показываем её
|
|
|
|
|
|
const showNextCard = () => {
|
|
|
|
|
|
// Проверяем, не завершился ли тест
|
|
|
|
|
|
if (isFinishingRef.current) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Используем функциональное обновление для получения актуального состояния пула
|
|
|
|
|
|
setTestWords(prevPool => {
|
|
|
|
|
|
// Повторная проверка внутри callback (на случай если состояние изменилось)
|
|
|
|
|
|
if (isFinishingRef.current) {
|
|
|
|
|
|
return prevPool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Используем ref для синхронного доступа к счётчику
|
|
|
|
|
|
const nextCardsShown = cardsShownRef.current + 1
|
|
|
|
|
|
|
|
|
|
|
|
// Условие 1: Достигли максимума карточек
|
|
|
|
|
|
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
|
|
|
|
|
finishTest()
|
|
|
|
|
|
return prevPool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Условие 2: Пул слов пуст
|
|
|
|
|
|
if (prevPool.length === 0) {
|
|
|
|
|
|
finishTest()
|
|
|
|
|
|
return prevPool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// getAndDelete: берём слово из пула и удаляем его
|
|
|
|
|
|
const nextWord = prevPool[0]
|
|
|
|
|
|
|
|
|
|
|
|
// Условие 3: Первое слово в пуле null/undefined (не должно происходить, но на всякий случай)
|
|
|
|
|
|
if (!nextWord) {
|
|
|
|
|
|
// Ищем первое не-null слово в пуле
|
|
|
|
|
|
const validWordIndex = prevPool.findIndex(w => w !== null && w !== undefined)
|
|
|
|
|
|
if (validWordIndex === -1) {
|
|
|
|
|
|
// Нет валидных слов - завершаем тест
|
|
|
|
|
|
finishTest()
|
|
|
|
|
|
return prevPool
|
|
|
|
|
|
}
|
|
|
|
|
|
// Берём валидное слово
|
|
|
|
|
|
const validWord = prevPool[validWordIndex]
|
|
|
|
|
|
const updatedPool = [...prevPool.slice(0, validWordIndex), ...prevPool.slice(validWordIndex + 1)]
|
|
|
|
|
|
|
|
|
|
|
|
// Синхронно обновляем ref
|
|
|
|
|
|
cardsShownRef.current = nextCardsShown
|
|
|
|
|
|
|
|
|
|
|
|
setCurrentWord(validWord)
|
|
|
|
|
|
setCardsShown(nextCardsShown)
|
|
|
|
|
|
setFlippedCards(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
return updatedPool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updatedPool = prevPool.slice(1)
|
|
|
|
|
|
|
|
|
|
|
|
// Синхронно обновляем ref ПЕРЕД установкой state
|
|
|
|
|
|
cardsShownRef.current = nextCardsShown
|
|
|
|
|
|
|
|
|
|
|
|
// showCard: показываем карточку
|
|
|
|
|
|
setCurrentWord(nextWord)
|
|
|
|
|
|
setCardsShown(nextCardsShown)
|
|
|
|
|
|
setFlippedCards(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
return updatedPool
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSuccess = (wordId) => {
|
|
|
|
|
|
if (processingRef.current || isFinishingRef.current || showResults) return
|
|
|
|
|
|
processingRef.current = true
|
|
|
|
|
|
|
|
|
|
|
|
const word = words.find(w => w.id === wordId)
|
|
|
|
|
|
if (!word) {
|
|
|
|
|
|
processingRef.current = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
|
|
|
|
|
|
|
|
// Обновляем статистику: success + 1, lastSuccessAt = now
|
|
|
|
|
|
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
|
|
|
|
|
const updatedStats = {
|
|
|
|
|
|
...wordStatsRef.current,
|
|
|
|
|
|
[wordId]: {
|
|
|
|
|
|
success: (currentWordStats.success || 0) + 1,
|
|
|
|
|
|
failure: currentWordStats.failure || 0,
|
|
|
|
|
|
lastSuccessAt: now,
|
|
|
|
|
|
lastFailureAt: currentWordStats.lastFailureAt || null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
wordStatsRef.current = updatedStats
|
|
|
|
|
|
setWordStats(updatedStats)
|
|
|
|
|
|
|
|
|
|
|
|
// Увеличиваем счётчик ответов
|
|
|
|
|
|
const newTotalAnswers = totalAnswers + 1
|
|
|
|
|
|
setTotalAnswers(newTotalAnswers)
|
|
|
|
|
|
|
|
|
|
|
|
// onSuccess: просто повторяем (showNextCard)
|
|
|
|
|
|
// Карточка уже удалена из пула при показе, просто показываем следующую
|
|
|
|
|
|
showNextCard()
|
|
|
|
|
|
|
|
|
|
|
|
// Если тест завершился, не сбрасываем processingRef
|
|
|
|
|
|
if (isFinishingRef.current) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
processingRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleFailure = (wordId) => {
|
|
|
|
|
|
if (processingRef.current || isFinishingRef.current || showResults) return
|
|
|
|
|
|
processingRef.current = true
|
|
|
|
|
|
|
|
|
|
|
|
const word = words.find(w => w.id === wordId)
|
|
|
|
|
|
if (!word) {
|
|
|
|
|
|
processingRef.current = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
|
|
|
|
|
|
|
|
// Обновляем статистику: failure + 1, lastFailureAt = now
|
|
|
|
|
|
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
|
|
|
|
|
const updatedStats = {
|
|
|
|
|
|
...wordStatsRef.current,
|
|
|
|
|
|
[wordId]: {
|
|
|
|
|
|
success: currentWordStats.success || 0,
|
|
|
|
|
|
failure: (currentWordStats.failure || 0) + 1,
|
|
|
|
|
|
lastSuccessAt: currentWordStats.lastSuccessAt || null,
|
|
|
|
|
|
lastFailureAt: now
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
wordStatsRef.current = updatedStats
|
|
|
|
|
|
setWordStats(updatedStats)
|
|
|
|
|
|
|
|
|
|
|
|
// Увеличиваем счётчик ответов
|
|
|
|
|
|
const newTotalAnswers = totalAnswers + 1
|
|
|
|
|
|
setTotalAnswers(newTotalAnswers)
|
|
|
|
|
|
|
|
|
|
|
|
// onFailure: возвращаем карточку в пул, сортируем, повторяем
|
|
|
|
|
|
setTestWords(prevPool => {
|
|
|
|
|
|
// cards.add(currentCard): возвращаем слово обратно в пул
|
|
|
|
|
|
let newTestWords = [...prevPool, word]
|
|
|
|
|
|
|
|
|
|
|
|
// cards.sort(): равномерно перераспределяем слова в пуле
|
|
|
|
|
|
// Передаём wordId, чтобы текущая карточка не оказалась первой (следующей для показа)
|
|
|
|
|
|
newTestWords = redistributeWordsEvenly(newTestWords, words, wordId)
|
|
|
|
|
|
|
|
|
|
|
|
return newTestWords
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// repeat(): показываем следующую карточку
|
|
|
|
|
|
showNextCard()
|
|
|
|
|
|
|
|
|
|
|
|
// Если тест завершился, не сбрасываем processingRef
|
|
|
|
|
|
if (isFinishingRef.current) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
processingRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
|
window.history.back()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleStartTest = () => {
|
|
|
|
|
|
setShowPreview(false)
|
|
|
|
|
|
// Показываем первую карточку (берём из пула)
|
|
|
|
|
|
showNextCard()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 16:07:36 +03:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getRandomSide = (word) => {
|
|
|
|
|
|
return word.id % 2 === 0 ? 'word' : 'translation'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="test-container test-container-fullscreen">
|
|
|
|
|
|
<button className="close-x-button" onClick={handleClose}>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{showPreview ? (
|
|
|
|
|
|
<div className="test-preview">
|
|
|
|
|
|
<div className="preview-stats">
|
|
|
|
|
|
{words.map((word) => {
|
|
|
|
|
|
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={word.id} className="preview-item">
|
|
|
|
|
|
<div className="preview-word-content">
|
|
|
|
|
|
<div className="preview-word-header">
|
|
|
|
|
|
<h3 className="preview-word">{word.name}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="preview-translation">{word.translation}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="preview-stats-numbers">
|
|
|
|
|
|
<span className="preview-stat-success">{stats.success}</span>
|
|
|
|
|
|
<span className="preview-stat-separator"> | </span>
|
|
|
|
|
|
<span className="preview-stat-failure">{stats.failure}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="preview-actions">
|
|
|
|
|
|
<button className="test-start-button" onClick={handleStartTest}>
|
|
|
|
|
|
Начать
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : showResults ? (
|
|
|
|
|
|
<div className="test-results">
|
|
|
|
|
|
<div className="results-stats">
|
|
|
|
|
|
{words.map((word) => {
|
|
|
|
|
|
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={word.id} className="result-item">
|
|
|
|
|
|
<div className="result-word-content">
|
|
|
|
|
|
<div className="result-word-header">
|
|
|
|
|
|
<h3 className="result-word">{word.name}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="result-translation">{word.translation}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="result-stats">
|
|
|
|
|
|
<span className="result-stat-success">{stats.success}</span>
|
|
|
|
|
|
<span className="result-stat-separator"> | </span>
|
|
|
|
|
|
<span className="result-stat-failure">{stats.failure}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="results-actions">
|
2026-02-09 16:07:36 +03:00
|
|
|
|
<button
|
|
|
|
|
|
className="test-finish-button"
|
|
|
|
|
|
onClick={handleFinish}
|
|
|
|
|
|
disabled={isClosing}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isClosing ? 'Завершение…' : 'Закончить'}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="test-screen">
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="fixed inset-0 flex justify-center items-center">
|
|
|
|
|
|
<div className="flex flex-col items-center">
|
|
|
|
|
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
|
|
|
|
|
<div className="text-gray-600 font-medium">Загрузка...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<LoadingError onRetry={() => {
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
// Перезагружаем слова
|
|
|
|
|
|
const loadWords = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (configId === null) {
|
|
|
|
|
|
throw new Error('config_id обязателен для запуска теста')
|
|
|
|
|
|
}
|
|
|
|
|
|
const url = `${API_URL}/test/words?config_id=${configId}`
|
|
|
|
|
|
const response = await authFetch(url)
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при загрузке слов')
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(data) || data.length === 0) {
|
|
|
|
|
|
throw new Error('Недостаточно слов для теста')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stats = {}
|
|
|
|
|
|
data.forEach(word => {
|
|
|
|
|
|
stats[word.id] = {
|
|
|
|
|
|
success: word.success || 0,
|
|
|
|
|
|
failure: word.failure || 0,
|
|
|
|
|
|
lastSuccessAt: word.last_success_at || null,
|
|
|
|
|
|
lastFailureAt: word.last_failure_at || null
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
setWords(data)
|
|
|
|
|
|
|
|
|
|
|
|
const wordsCount = data.length
|
|
|
|
|
|
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
|
|
|
|
|
|
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
|
|
|
|
|
|
|
|
|
|
|
let wordPool = []
|
|
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
|
|
|
|
wordPool.push(...data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wordPool = redistributeWordsEvenly(wordPool, data)
|
|
|
|
|
|
|
|
|
|
|
|
setTestWords(wordPool)
|
|
|
|
|
|
setWordStats(stats)
|
|
|
|
|
|
wordStatsRef.current = stats
|
|
|
|
|
|
setShowPreview(true)
|
|
|
|
|
|
setCardsShown(0)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
loadWords()
|
|
|
|
|
|
}} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!loading && !error && !isFinishingRef.current && currentWord && (() => {
|
|
|
|
|
|
const word = currentWord
|
|
|
|
|
|
const isFlipped = flippedCards.has(word.id)
|
|
|
|
|
|
const showSide = getRandomSide(word)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="test-card-container" key={word.id}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`test-card ${isFlipped ? 'flipped' : ''}`}
|
|
|
|
|
|
onClick={() => handleCardFlip(word.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="test-card-front">
|
|
|
|
|
|
<div className="test-card-content">
|
|
|
|
|
|
{showSide === 'word' ? (
|
|
|
|
|
|
<div className="test-word">{word.name}</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="test-translation">{word.translation}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="test-card-back">
|
|
|
|
|
|
<div className="test-card-content">
|
|
|
|
|
|
{showSide === 'word' ? (
|
|
|
|
|
|
<div className="test-translation">{word.translation}</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="test-word">{word.name}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="test-card-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="test-action-button success-button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
handleSuccess(word.id)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
✓ Знаю
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="test-action-button failure-button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
handleFailure(word.id)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
✗ Не знаю
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})()}
|
|
|
|
|
|
{!loading && !error && (
|
|
|
|
|
|
<div className="test-progress">
|
|
|
|
|
|
<div className="progress-text">
|
|
|
|
|
|
{cardsShown} / {getRightCounter()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default TestWords
|