Files
play-life/play-life-web/src/components/TestWords.jsx

697 lines
28 KiB
React
Raw Normal View History

2025-12-29 20:01:55 +03:00
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
2025-12-29 20:01:55 +03:00
import './TestWords.css'
import './Integrations.css'
2025-12-29 20:01:55 +03:00
const API_URL = '/api'
const DEFAULT_TEST_WORD_COUNT = 10
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
const { authFetch } = useAuth()
2025-12-29 20:01:55 +03:00
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
const configId = initialConfigId || null
const maxCards = initialMaxCards || null
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
const [testWords, setTestWords] = useState([]) // Пул слов для показа
const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула)
2025-12-29 20:01:55 +03:00
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) // Показывать ли экран результатов
const isFinishingRef = useRef(false)
const wordStatsRef = useRef({})
const processingRef = useRef(false)
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
2025-12-29 20:01:55 +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
}
2025-12-29 20:01:55 +03:00
// Загрузка слов при монтировании
useEffect(() => {
setWords([])
setTestWords([])
setCurrentWord(null)
2025-12-29 20:01:55 +03:00
setFlippedCards(new Set())
setWordStats({})
wordStatsRef.current = {}
setCardsShown(0)
cardsShownRef.current = 0 // Сбрасываем синхронный счётчик
2025-12-29 20:01:55 +03:00
setTotalAnswers(0)
setError('')
setShowPreview(false) // Сбрасываем экран предпросмотра
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
isFinishingRef.current = false
processingRef.current = false
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)
2025-12-29 20:01:55 +03:00
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 = []
2025-12-29 20:01:55 +03:00
for (let i = 0; i < n; i++) {
wordPool.push(...data)
}
// Равномерно распределяем слова в пуле
wordPool = redistributeWordsEvenly(wordPool, data)
2025-12-29 20:01:55 +03:00
setTestWords(wordPool)
setWordStats(stats)
wordStatsRef.current = stats
// Показываем экран предпросмотра
setShowPreview(true)
// Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста)
setCardsShown(0)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
loadWords()
}, [wordCount, configId])
// Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards
2025-12-29 20:01:55 +03:00
const getRightCounter = () => {
const total = testWords.length + cardsShown
2025-12-29 20:01:55 +03:00
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
})
2025-12-29 20:01:55 +03:00
}
// Завершение теста
const finishTest = async () => {
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 requestBody = { words: updates }
if (configId !== null) {
requestBody.config_id = configId
}
console.log('Sending test progress to backend:', {
wordsCount: updates.length,
configId: configId,
requestBody
})
const response = await authFetch(`${API_URL}/test/progress`, {
2025-12-29 20:01:55 +03:00
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)
} catch (err) {
console.error('Failed to save progress:', err)
// Можно показать уведомление пользователю, но не блокируем показ результатов
}
}
// Берём карточку из пула (getAndDelete) и показываем её
const showNextCard = () => {
// Проверяем, не завершился ли тест
if (isFinishingRef.current) {
2025-12-29 20:01:55 +03:00
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
})
2025-12-29 20:01:55 +03:00
}
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()
2025-12-29 20:01:55 +03:00
// Если тест завершился, не сбрасываем 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
})
2025-12-29 20:01:55 +03:00
// repeat(): показываем следующую карточку
showNextCard()
2025-12-29 20:01:55 +03:00
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
return
}
processingRef.current = false
}
const handleClose = () => {
onNavigate?.('test-config')
}
const handleStartTest = () => {
setShowPreview(false)
// Показываем первую карточку (берём из пула)
showNextCard()
2025-12-29 20:01:55 +03:00
}
const handleFinish = () => {
onNavigate?.('test-config')
}
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}>
2025-12-29 20:01:55 +03:00
</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">
<button className="test-finish-button" onClick={handleFinish}>
Закончить
</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>
2025-12-29 20:01:55 +03:00
)}
{error && (
<div className="test-error">{error}</div>
)}
{!loading && !error && !isFinishingRef.current && currentWord && (() => {
const word = currentWord
2025-12-29 20:01:55 +03:00
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)}
2025-12-29 20:01:55 +03:00
>
<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