fix: исправлена логика распределения слов в тесте и race condition
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:
poignatov
2026-01-09 14:40:45 +03:00
parent ef59781633
commit 6cf4be65b2
9 changed files with 182 additions and 66 deletions

View File

@@ -1 +1 @@
3.5.4
3.5.5

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "3.5.4",
"version": "3.5.5",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -706,12 +706,20 @@ function AppContent() {
// Определяем отступы для контейнера
const getContainerPadding = () => {
if (!isFullscreenTab) {
// Для tasks и profile на широких экранах увеличиваем отступ
if (activeTab === 'tasks' || activeTab === 'profile') {
return 'p-4 md:p-8'
}
return 'p-4 md:p-6'
}
// Для экрана статистики добавляем горизонтальные отступы
if (activeTab === 'full') {
return 'px-4 md:px-6 py-0'
}
// Для экрана приоритетов используем такие же отступы как для profile
if (activeTab === 'priorities') {
return 'px-4 md:px-8 py-0'
}
// Для остальных fullscreen экранов без отступов
return 'p-0'
}

View File

@@ -225,7 +225,7 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
</button>
)}
<div style={{ height: '550px' }}>
<div style={{ height: '550px', paddingTop: '60px' }}>
<Line data={chartData} options={chartOptions} />
</div>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />

View File

@@ -16,7 +16,7 @@ function Profile({ onNavigate }) {
}
return (
<div className="p-4 md:p-6 max-w-2xl mx-auto">
<div className="max-w-2xl mx-auto">
{/* 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="flex items-center space-x-4">

View File

@@ -865,7 +865,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
const activeProject = allItems.find(item => item.name === activeId)
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 && (
<button
onClick={() => onNavigate('current')}

View File

@@ -1,6 +1,5 @@
.task-list {
padding: 1rem;
max-width: 800px;
max-width: 42rem; /* max-w-2xl = 672px */
margin: 0 auto;
}

View File

@@ -1,14 +1,5 @@
.config-selection {
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 {

View File

@@ -28,6 +28,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
const isFinishingRef = useRef(false)
const wordStatsRef = useRef({})
const processingRef = useRef(false)
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
@@ -50,73 +51,155 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
return currentPool
}
// Перемешиваем уникальные слова для случайности порядка
const shuffledUniqueWords = [...uniqueWords]
for (let i = shuffledUniqueWords.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffledUniqueWords[i], shuffledUniqueWords[j]] = [shuffledUniqueWords[j], shuffledUniqueWords[i]]
// Проверяем, есть ли в пуле слова, отличные от исключаемого
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 })
}
}
// Создаём массив с информацией о каждом слове и его количестве вхождений
const wordEntries = shuffledUniqueWords.map(word => ({
word,
count: wordCounts[word.id]
}))
// Сортируем по убыванию количества вхождений (слова с большим количеством вхождений размещаем первыми)
wordEntries.sort((a, b) => b.count - a.count)
// Перемешиваем экземпляры
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 (const entry of wordEntries) {
const { word, count } = entry
for (let pos = 0; pos < totalSlots; pos++) {
let bestWord = null
let bestWordIndex = -1
let bestDistance = -1
// Вычисляем идеальный интервал между вхождениями этого слова
const interval = totalSlots / count
for (let i = 0; i < allInstances.length; i++) {
const word = allInstances[i]
// Размещаем каждое вхождение слова
for (let i = 0; i < count; i++) {
// Начинаем с идеальной позиции
let idealPos = Math.floor(i * interval + Math.random() * interval * 0.5)
idealPos = Math.min(idealPos, totalSlots - 1)
// Если это исключаемое слово, не размещаем его на позиции 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) {
// Для позиции 0: не выбираем исключаемое слово, если есть альтернативы
if (pos === 0 && word.id === effectiveExcludeId) {
// Проверяем, есть ли другие слова
const hasAlternative = allInstances.some(w => w.id !== effectiveExcludeId)
if (hasAlternative) {
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 (excludeFirstWordId !== null && newPool[0] && newPool[0].id === excludeFirstWordId) {
// Ищем первое слово, которое не является исключаемым
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 !== excludeFirstWordId) {
// Меняем местами
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
}
@@ -129,6 +212,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
setWordStats({})
wordStatsRef.current = {}
setCardsShown(0)
cardsShownRef.current = 0 // Сбрасываем синхронный счётчик
setTotalAnswers(0)
setError('')
setShowPreview(false) // Сбрасываем экран предпросмотра
@@ -290,14 +374,21 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
// Берём карточку из пула (getAndDelete) и показываем её
const showNextCard = () => {
// Проверяем, не завершился ли тест
if (isFinishingRef.current || showResults) {
if (isFinishingRef.current) {
return
}
// Используем функциональное обновление для получения актуального состояния пула
setTestWords(prevPool => {
// Повторная проверка внутри callback (на случай если состояние изменилось)
if (isFinishingRef.current) {
return prevPool
}
// Используем ref для синхронного доступа к счётчику
const nextCardsShown = cardsShownRef.current + 1
// Условие 1: Достигли максимума карточек
const nextCardsShown = cardsShown + 1
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
finishTest()
return prevPool
@@ -311,8 +402,35 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
// 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)