fix: исправлена логика распределения слов в тесте и race condition
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
- Переписан алгоритм redistributeWordsEvenly с жадным подходом - Добавлена пост-обработка для исправления последовательных дубликатов - Исключаемое слово (текущее) теперь корректно не появляется первым - Исправлен race condition с cardsShown через использование ref - Добавлена проверка на null/undefined слова в пуле v3.5.5
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.5.4",
|
"version": "3.5.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -706,12 +706,20 @@ function AppContent() {
|
|||||||
// Определяем отступы для контейнера
|
// Определяем отступы для контейнера
|
||||||
const getContainerPadding = () => {
|
const getContainerPadding = () => {
|
||||||
if (!isFullscreenTab) {
|
if (!isFullscreenTab) {
|
||||||
|
// Для tasks и profile на широких экранах увеличиваем отступ
|
||||||
|
if (activeTab === 'tasks' || activeTab === 'profile') {
|
||||||
|
return 'p-4 md:p-8'
|
||||||
|
}
|
||||||
return 'p-4 md:p-6'
|
return 'p-4 md:p-6'
|
||||||
}
|
}
|
||||||
// Для экрана статистики добавляем горизонтальные отступы
|
// Для экрана статистики добавляем горизонтальные отступы
|
||||||
if (activeTab === 'full') {
|
if (activeTab === 'full') {
|
||||||
return 'px-4 md:px-6 py-0'
|
return 'px-4 md:px-6 py-0'
|
||||||
}
|
}
|
||||||
|
// Для экрана приоритетов используем такие же отступы как для profile
|
||||||
|
if (activeTab === 'priorities') {
|
||||||
|
return 'px-4 md:px-8 py-0'
|
||||||
|
}
|
||||||
// Для остальных fullscreen экранов без отступов
|
// Для остальных fullscreen экранов без отступов
|
||||||
return 'p-0'
|
return 'p-0'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div style={{ height: '550px' }}>
|
<div style={{ height: '550px', paddingTop: '60px' }}>
|
||||||
<Line data={chartData} options={chartOptions} />
|
<Line data={chartData} options={chartOptions} />
|
||||||
</div>
|
</div>
|
||||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function Profile({ onNavigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
{/* Profile Header */}
|
{/* Profile Header */}
|
||||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 mb-6 text-white shadow-lg">
|
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 mb-6 text-white shadow-lg">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
|||||||
@@ -865,7 +865,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
const activeProject = allItems.find(item => item.name === activeId)
|
const activeProject = allItems.find(item => item.name === activeId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto flex flex-col max-h-[calc(100vh-11rem)]">
|
<div className="max-w-2xl mx-auto flex flex-col max-h-[calc(100vh-11rem)] pt-[60px]">
|
||||||
{onNavigate && (
|
{onNavigate && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate('current')}
|
onClick={() => onNavigate('current')}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.task-list {
|
.task-list {
|
||||||
padding: 1rem;
|
max-width: 42rem; /* max-w-2xl = 672px */
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
.config-selection {
|
.config-selection {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.config-selection {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
padding-right: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-config-button {
|
.add-config-button {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const isFinishingRef = useRef(false)
|
const isFinishingRef = useRef(false)
|
||||||
const wordStatsRef = useRef({})
|
const wordStatsRef = useRef({})
|
||||||
const processingRef = useRef(false)
|
const processingRef = useRef(false)
|
||||||
|
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
|
||||||
|
|
||||||
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
||||||
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
||||||
@@ -50,73 +51,155 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
return currentPool
|
return currentPool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перемешиваем уникальные слова для случайности порядка
|
// Проверяем, есть ли в пуле слова, отличные от исключаемого
|
||||||
const shuffledUniqueWords = [...uniqueWords]
|
const hasOtherWords = uniqueWords.some(w => w.id !== excludeFirstWordId)
|
||||||
for (let i = shuffledUniqueWords.length - 1; i > 0; i--) {
|
const effectiveExcludeId = hasOtherWords ? excludeFirstWordId : null
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[shuffledUniqueWords[i], shuffledUniqueWords[j]] = [shuffledUniqueWords[j], shuffledUniqueWords[i]]
|
// Создаём массив всех экземпляров слов для распределения
|
||||||
|
const allInstances = []
|
||||||
|
for (const word of uniqueWords) {
|
||||||
|
const count = wordCounts[word.id]
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
allInstances.push({ ...word })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаём массив с информацией о каждом слове и его количестве вхождений
|
// Перемешиваем экземпляры
|
||||||
const wordEntries = shuffledUniqueWords.map(word => ({
|
for (let i = allInstances.length - 1; i > 0; i--) {
|
||||||
word,
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
count: wordCounts[word.id]
|
;[allInstances[i], allInstances[j]] = [allInstances[j], allInstances[i]]
|
||||||
}))
|
}
|
||||||
|
|
||||||
// Сортируем по убыванию количества вхождений (слова с большим количеством вхождений размещаем первыми)
|
|
||||||
wordEntries.sort((a, b) => b.count - a.count)
|
|
||||||
|
|
||||||
|
// Используем жадный алгоритм: на каждую позицию выбираем слово,
|
||||||
|
// которое максимально далеко от своего последнего появления
|
||||||
const totalSlots = currentPool.length
|
const totalSlots = currentPool.length
|
||||||
const newPool = new Array(totalSlots).fill(null)
|
const newPool = new Array(totalSlots).fill(null)
|
||||||
|
const lastPosition = {} // Последняя позиция каждого слова
|
||||||
|
|
||||||
// Размещаем каждое слово с максимально возможным расстоянием между его вхождениями
|
for (let pos = 0; pos < totalSlots; pos++) {
|
||||||
for (const entry of wordEntries) {
|
let bestWord = null
|
||||||
const { word, count } = entry
|
let bestWordIndex = -1
|
||||||
|
let bestDistance = -1
|
||||||
|
|
||||||
// Вычисляем идеальный интервал между вхождениями этого слова
|
for (let i = 0; i < allInstances.length; i++) {
|
||||||
const interval = totalSlots / count
|
const word = allInstances[i]
|
||||||
|
|
||||||
// Размещаем каждое вхождение слова
|
// Для позиции 0: не выбираем исключаемое слово, если есть альтернативы
|
||||||
for (let i = 0; i < count; i++) {
|
if (pos === 0 && word.id === effectiveExcludeId) {
|
||||||
// Начинаем с идеальной позиции
|
// Проверяем, есть ли другие слова
|
||||||
let idealPos = Math.floor(i * interval + Math.random() * interval * 0.5)
|
const hasAlternative = allInstances.some(w => w.id !== effectiveExcludeId)
|
||||||
idealPos = Math.min(idealPos, totalSlots - 1)
|
if (hasAlternative) {
|
||||||
|
|
||||||
// Если это исключаемое слово, не размещаем его на позиции 0
|
|
||||||
if (word.id === excludeFirstWordId && idealPos === 0) {
|
|
||||||
idealPos = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ищем ближайшую свободную позицию
|
|
||||||
let placed = false
|
|
||||||
|
|
||||||
// Сначала ищем вперёд
|
|
||||||
for (let offset = 0; offset < totalSlots && !placed; offset++) {
|
|
||||||
const pos = (idealPos + offset) % totalSlots
|
|
||||||
// Пропускаем позицию 0 для исключаемого слова
|
|
||||||
if (word.id === excludeFirstWordId && pos === 0) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (newPool[pos] === null) {
|
|
||||||
newPool[pos] = word
|
|
||||||
placed = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вычисляем расстояние от последнего появления этого слова
|
||||||
|
const lastPos = lastPosition[word.id]
|
||||||
|
const distance = lastPos === undefined ? totalSlots : (pos - lastPos)
|
||||||
|
|
||||||
|
// Выбираем слово с максимальным расстоянием
|
||||||
|
if (distance > bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestWord = word
|
||||||
|
bestWordIndex = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем его с другим
|
if (bestWord !== null) {
|
||||||
if (excludeFirstWordId !== null && newPool[0] && newPool[0].id === excludeFirstWordId) {
|
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++) {
|
for (let i = 1; i < newPool.length; i++) {
|
||||||
if (newPool[i] && newPool[i].id !== excludeFirstWordId) {
|
if (newPool[i] && newPool[i].id !== effectiveExcludeId) {
|
||||||
// Меняем местами
|
|
||||||
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
|
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
|
||||||
break
|
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
|
return newPool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +212,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
setWordStats({})
|
setWordStats({})
|
||||||
wordStatsRef.current = {}
|
wordStatsRef.current = {}
|
||||||
setCardsShown(0)
|
setCardsShown(0)
|
||||||
|
cardsShownRef.current = 0 // Сбрасываем синхронный счётчик
|
||||||
setTotalAnswers(0)
|
setTotalAnswers(0)
|
||||||
setError('')
|
setError('')
|
||||||
setShowPreview(false) // Сбрасываем экран предпросмотра
|
setShowPreview(false) // Сбрасываем экран предпросмотра
|
||||||
@@ -290,14 +374,21 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
// Берём карточку из пула (getAndDelete) и показываем её
|
// Берём карточку из пула (getAndDelete) и показываем её
|
||||||
const showNextCard = () => {
|
const showNextCard = () => {
|
||||||
// Проверяем, не завершился ли тест
|
// Проверяем, не завершился ли тест
|
||||||
if (isFinishingRef.current || showResults) {
|
if (isFinishingRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Используем функциональное обновление для получения актуального состояния пула
|
// Используем функциональное обновление для получения актуального состояния пула
|
||||||
setTestWords(prevPool => {
|
setTestWords(prevPool => {
|
||||||
|
// Повторная проверка внутри callback (на случай если состояние изменилось)
|
||||||
|
if (isFinishingRef.current) {
|
||||||
|
return prevPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем ref для синхронного доступа к счётчику
|
||||||
|
const nextCardsShown = cardsShownRef.current + 1
|
||||||
|
|
||||||
// Условие 1: Достигли максимума карточек
|
// Условие 1: Достигли максимума карточек
|
||||||
const nextCardsShown = cardsShown + 1
|
|
||||||
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
||||||
finishTest()
|
finishTest()
|
||||||
return prevPool
|
return prevPool
|
||||||
@@ -311,8 +402,35 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
|
|
||||||
// getAndDelete: берём слово из пула и удаляем его
|
// getAndDelete: берём слово из пула и удаляем его
|
||||||
const nextWord = prevPool[0]
|
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)
|
const updatedPool = prevPool.slice(1)
|
||||||
|
|
||||||
|
// Синхронно обновляем ref ПЕРЕД установкой state
|
||||||
|
cardsShownRef.current = nextCardsShown
|
||||||
|
|
||||||
// showCard: показываем карточку
|
// showCard: показываем карточку
|
||||||
setCurrentWord(nextWord)
|
setCurrentWord(nextWord)
|
||||||
setCardsShown(nextCardsShown)
|
setCardsShown(nextCardsShown)
|
||||||
|
|||||||
Reference in New Issue
Block a user