Initial commit

This commit is contained in:
poignatov
2025-12-29 20:01:55 +03:00
commit 4f8a793377
63 changed files with 13655 additions and 0 deletions

View File

@@ -0,0 +1,490 @@
import React, { useState, useEffect, useRef } from 'react'
import './TestWords.css'
const API_URL = '/api'
const DEFAULT_TEST_WORD_COUNT = 10
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
const configId = initialConfigId || null
const maxCards = initialMaxCards || null
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
const [testWords, setTestWords] = useState([]) // Пул слов для показа
const [currentIndex, setCurrentIndex] = useState(0)
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)
// Загрузка слов при монтировании
useEffect(() => {
setWords([])
setTestWords([])
setCurrentIndex(0)
setFlippedCards(new Set())
setWordStats({})
wordStatsRef.current = {}
setCardsShown(0)
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 fetch(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 раз
const wordPool = []
for (let i = 0; i < n; i++) {
wordPool.push(...data)
}
// Перемешиваем пул случайным образом
for (let i = wordPool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
}
setTestWords(wordPool)
setWordStats(stats)
wordStatsRef.current = stats
// Показываем экран предпросмотра
setShowPreview(true)
// Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста)
setCardsShown(0)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
loadWords()
}, [wordCount, configId])
const getCurrentWord = () => {
if (currentIndex < testWords.length && testWords.length > 0) {
return testWords[currentIndex]
}
return null
}
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
const getRightCounter = () => {
const total = totalAnswers + testWords.length
if (maxCards !== null && maxCards > 0) {
return Math.min(total, maxCards)
}
return total
}
const handleCardFlip = (wordId) => {
setFlippedCards(prev => new Set(prev).add(wordId))
}
// Завершение теста
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 fetch(`${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)
} catch (err) {
console.error('Failed to save progress:', err)
// Можно показать уведомление пользователю, но не блокируем показ результатов
}
}
// Проверка условий завершения и показ следующей карточки
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
if (isFinishingRef.current || showResults) {
return
}
// Условие 1: Достигли максимума карточек
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
finishTest()
return
}
// Условие 2: Пул слов пуст
if (newTestWords.length === 0) {
finishTest()
return
}
// Показываем следующую карточку и увеличиваем левый счётчик
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
const nextCardsShown = currentCardsShown + 1
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
finishTest()
return
}
setCardsShown(nextCardsShown)
}
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)
// Убираем только один экземпляр слова из пула (по текущему индексу)
const newTestWords = [...testWords]
newTestWords.splice(currentIndex, 1)
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
let newIndex = currentIndex
if (newTestWords.length === 0) {
newIndex = 0
} else if (currentIndex >= newTestWords.length) {
newIndex = newTestWords.length - 1
}
// Обновляем состояние
setTestWords(newTestWords)
setCurrentIndex(newIndex)
setFlippedCards(new Set())
// Проверяем условия завершения или показываем следующую карточку
showNextCardOrFinish(newTestWords, cardsShown)
// Если тест завершился, не сбрасываем 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)
// Слово остаётся в пуле, переходим к следующему
let newIndex = currentIndex + 1
if (newIndex >= testWords.length) {
newIndex = 0
}
setCurrentIndex(newIndex)
setFlippedCards(new Set())
// Проверяем условия завершения или показываем следующую карточку
// При failure пул не изменяется
showNextCardOrFinish(testWords, cardsShown)
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
return
}
processingRef.current = false
}
const handleClose = () => {
onNavigate?.('test-config')
}
const handleStartTest = () => {
setShowPreview(false)
// Показываем первую карточку и увеличиваем левый счётчик
setCardsShown(1)
}
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="test-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">
<button className="test-finish-button" onClick={handleFinish}>
Закончить
</button>
</div>
</div>
) : (
<div className="test-screen">
{loading && (
<div className="test-loading">Загрузка слов...</div>
)}
{error && (
<div className="test-error">{error}</div>
)}
{!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => {
const word = getCurrentWord()
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={() => !isFlipped && 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