diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..d9e362e --- /dev/null +++ b/claude.md @@ -0,0 +1,420 @@ +# Правила работы с Challenge Platform + +Этот документ содержит важные правила и паттерны для работы с кодовой базой проекта Challenge Platform. + +## 🎯 Chakra UI - Специфика проекта + +### TypeScript и Chakra UI Props + +Проект использует Chakra UI v2/v3 с особенностями типизации. **ВАЖНО:** + +#### ✅ Правильное подавление ошибок TypeScript: + +```tsx +// ❌ НЕПРАВИЛЬНО - комментарий на отдельной строке НЕ работает: +{/* @ts-expect-error Chakra UI v2 uses isDisabled */} + + +// ✅ ПРАВИЛЬНО - комментарий перед элементом + все props на одной строке: +{/* @ts-expect-error Chakra UI v2 uses isDisabled */} + + +// ✅ ПРАВИЛЬНО - inline комментарий после prop: + +``` + +#### Частые props требующие подавления: + +- `isDisabled` (Button, Input) +- `isLoading` (Button) +- `isInvalid` (Input) +- `spacing` (VStack, HStack) + +### VStack и HStack + +```tsx +// ✅ ПРАВИЛЬНО: +{/* @ts-expect-error Chakra UI v2 uses spacing */} + + {/* content */} + +``` + +## 💾 LocalStorage - Ключи и паттерны + +### Константы для ключей + +**ВСЕГДА** используйте константы для ключей localStorage: + +```tsx +// ✅ ПРАВИЛЬНО: +const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber' +const USER_ID_KEY = 'challengeUserId' +const USER_NICKNAME_KEY = 'challengeNickname' +const SELECTED_CHAIN_KEY = 'challengeSelectedChainId' +const SELECTED_TASK_KEY = 'challengeSelectedTaskId' + +// Префикс 'challenge' для всех ключей проекта +``` + +### Синхронизация localStorage и Context + +Если добавляете новое поле в Context, которое должно сохраняться: + +1. **Добавьте константу** для ключа +2. **Инициализируйте state** из localStorage: + ```tsx + const [field, setField] = useState(() => + isBrowser() ? window.localStorage.getItem(FIELD_KEY) : null, + ) + ``` +3. **Сохраняйте в localStorage** при изменении (login) +4. **Очищайте из localStorage** при выходе (logout) +5. **Добавьте в интерфейс** ChallengeContextValue +6. **Добавьте в useMemo** (value и dependencies) + +## 🔄 Context - Паттерн обновления + +### Добавление нового поля в ChallengeContext + +Пример: добавление `workplaceNumber` + +**Шаг 1:** Интерфейс +```tsx +interface ChallengeContextValue { + // ... существующие поля + workplaceNumber: string | null // ← добавить +} +``` + +**Шаг 2:** Константа +```tsx +const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber' +``` + +**Шаг 3:** State с инициализацией +```tsx +const [workplaceNumber, setWorkplaceNumber] = useState(() => + isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null, +) +``` + +**Шаг 4:** Обновление login +```tsx +const login = useCallback( + async (nicknameValue: string, workplaceNumberValue: string) => { + // ... существующий код + setWorkplaceNumber(workplaceNumberValue) + + if (isBrowser()) { + // ... существующие setItem + window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue) + } + }, + [authUser, refreshStatsById], +) +``` + +**Шаг 5:** Обновление logout +```tsx +const logout = useCallback(() => { + // ... существующие setNull + setWorkplaceNumber(null) + + if (isBrowser()) { + // ... существующие removeItem + window.localStorage.removeItem(WORKPLACE_NUMBER_KEY) + } +}, []) +``` + +**Шаг 6:** Добавить в value +```tsx +const value = useMemo( + () => ({ + // ... существующие поля + workplaceNumber, // ← добавить + }), + [ + // ... существующие зависимости + workplaceNumber, // ← добавить в dependencies + ], +) +``` + +## 📝 Форматирование кода + +### Отступы +- **2 пробела** для отступов +- Никаких табов +- JSX атрибуты выравниваются по отступам родителя + 2 пробела + +### Комментарии на русском +```tsx +// ✅ ПРАВИЛЬНО: +// Проверяем, есть ли сохранённый номер рабочего места +useEffect(() => { + const savedWorkplace = localStorage.getItem(WORKPLACE_NUMBER_KEY) + // ... +}, []) +``` + +## 🔐 Паттерн двухшаговой формы + +Если нужна форма с несколькими шагами: + +```tsx +export const MultiStepForm = () => { + const [step, setStep] = useState<'step1' | 'step2'>('step1') + + // Проверка сохранённых данных + useEffect(() => { + const savedData = localStorage.getItem(SAVED_DATA_KEY) + if (savedData) { + setData(savedData) + setStep('step2') // Пропускаем первый шаг + } + }, []) + + const handleStep1Submit = (e: React.FormEvent) => { + e.preventDefault() + // Сохраняем СРАЗУ после валидации + localStorage.setItem(SAVED_DATA_KEY, trimmedData) + setStep('step2') + } + + // Условный рендеринг + if (step === 'step1') { + return + } + + return +} +``` + +### ⚠️ Важные моменты: +1. Сохраняйте данные **сразу** после валидации, не ждите финального submit +2. Проверяйте localStorage при монтировании компонента +3. Пропускайте шаги, если данные уже есть +4. Давайте возможность вернуться и изменить данные + +## 🎨 Header - Паттерн отображения информации + +Порядок отображения в Header: +``` +Место №{number} • {nickname} • {taskProgress} +``` + +Важные элементы отмечаем `fontWeight="medium"`: +```tsx + + Место №{workplaceNumber} + +``` + +## 🐛 Частые ошибки и решения + +### ❌ Ошибка: "Property 'spacing' does not exist" +**Решение:** Добавить `@ts-expect-error` комментарий перед VStack/HStack + +### ❌ Ошибка: "Unused '@ts-expect-error' directive" +**Причина:** Комментарий расположен неправильно +**Решение:** Поместить комментарий **непосредственно** перед элементом с ошибкой, и все props элемента должны быть на одной строке или с inline комментариями + +### ❌ Ошибка: Данные не сохраняются между сессиями +**Проверить:** +1. Используется ли `localStorage.setItem`? +2. Инициализируется ли state из localStorage? +3. Очищается ли в logout? +4. Синхронизированы ли ключи (одинаковые константы)? + +### ❌ Ошибка: Context не обновляется +**Проверить:** +1. Добавлено ли новое поле в `useMemo` value? +2. Добавлено ли новое поле в dependencies массив `useMemo`? +3. Добавлено ли в интерфейс `ChallengeContextValue`? + +## 📚 Структура проекта + +``` +src/ +├── components/ # Компоненты +│ ├── LoginForm.tsx # Форма входа (может быть многошаговой) +│ ├── Header.tsx # Шапка с информацией о пользователе +│ └── ... +├── context/ # React Context +│ └── ChallengeContext.tsx # Главный контекст приложения +├── pages/ # Страницы +│ └── main/ +└── ... +``` + +## ✅ Чеклист перед коммитом + +- [ ] Нет linter errors (проверить `read_lints`) +- [ ] TypeScript ошибки подавлены правильно +- [ ] LocalStorage ключи используют константы +- [ ] Новые поля Context добавлены во все нужные места +- [ ] Комментарии на русском языке +- [ ] Отступы - 2 пробела +- [ ] Данные сохраняются и восстанавливаются из localStorage + +## 🎯 Динамическая очередь проверки с прогресс-баром + +### Симуляция очереди в API (stubs/api/index.js) + +Для лучшего UX очередь проверки показывает прогресс через прогресс-бар. + +**Паттерн реализации:** + +```javascript +// Таймауты: 100ms для быстрой работы в dev +const timer = (time = 100) => (req, res, next) => setTimeout(next, time) + +// Храним состояние для каждого queueId +const queueStates = {} + +router.get('/challenge/check-status/:queueId', (req, res) => { + const queueId = req.params.queueId + + // Инициализируем с позицией 3 + if (!queueStates[queueId]) { + queueStates[queueId] = { + position: 3, + initialPosition: 3, // Для расчёта прогресса + pollCount: 0 + } + } + + const state = queueStates[queueId] + state.pollCount++ + + // Каждый запрос уменьшаем позицию (быстро) + if (state.pollCount >= 1 && state.position > 0) { + state.position-- + state.pollCount = 0 + } + + // Когда позиция 0 → возвращаем результат проверки + if (state.position === 0 && state.pollCount >= 1) { + delete queueStates[queueId] // Очищаем + return res.json({ status: 'completed', submission: {...} }) + } + + // Возвращаем текущую позицию + начальную для прогресса + return res.json({ + status: state.position > 0 ? 'waiting' : 'in_progress', + position: state.position, + initialPosition: state.initialPosition // ← Важно! + }) +}) +``` + +### UI с прогресс-баром + +**Новый подход:** Вместо отображения номера в очереди показываем прогресс-бар. + +**Расчёт прогресса:** + +```tsx +const checkingProgress = (() => { + if (!queueStatus) return 0 + + const initial = queueStatus.initialPosition || 3 + const current = queueStatus.position || 0 + + if (queueStatus.status === 'in_progress') return 90 // Почти готово + + // От 0% до 80% по мере движения в очереди + const progress = ((initial - current) / initial) * 80 + return Math.max(10, progress) // Минимум 10% чтобы было видно +})() +``` + +**Кастомный прогресс-бар с анимацией:** + +```tsx + + + {/* Анимированный блик */} + + + +``` + +**Текст под прогресс-баром:** +```tsx + + {checkingProgress < 50 ? 'Ожидание в очереди' : + checkingProgress < 90 ? 'Начинаем проверку' : + 'Почти готово'} + +``` + +### ⚠️ Важно + +- **Таймауты в dev:** 100ms для быстрой работы. На prod бэкенд будет быстрее +- **Прогресс:** 0-80% = движение в очереди, 80-90% = начало проверки, 90%+ = финализация +- **Не показываем номер** в очереди - только прогресс-бар +- **Текст:** простой "Проверяем решение..." вместо деталей +- **Анимация shimmer:** добавляет ощущение процесса +- Полинг: интервал 2 секунды (`PollingManager`) +- **КРИТИЧНО:** При смене задания сбрасывайте все локальные состояния + +### Сброс состояния при смене задания + +**ОБЯЗАТЕЛЬНО** добавляйте useEffect для сброса локальных состояний: + +```tsx +// Сбрасываем состояние при смене задания +useEffect(() => { + setLastResult(null) + setPrevPosition(null) + setPositionChanged(false) + // ... любые другие локальные состояния +}, [task.id]) // ← зависимость от task.id +``` + +**Почему это важно:** +- Без сброса плашки успеха/ошибки от предыдущего задания остаются видимыми +- Пользователь видит некорректную информацию +- Состояние очереди "протекает" между заданиями + +## 🎓 Философия проекта + +1. **Удобство пользователя превыше всего** - сохраняем данные, не переспрашиваем +2. **Типобезопасность** - используем TypeScript, подавляем ошибки осознанно +3. **Консистентность** - следуем установленным паттернам +4. **Персистентность** - важные данные (номер места, выбор цепочки) сохраняются в localStorage +5. **Обратная связь** - пользователь всегда видит что происходит (позиция в очереди, прогресс проверки) + +--- + +**Версия:** 1.1 +**Дата:** 13.12.2025 +**Автор:** Claude (на основе опыта работы с проектом) + diff --git a/src/__data__/types.ts b/src/__data__/types.ts index 879cc15..c887635 100644 --- a/src/__data__/types.ts +++ b/src/__data__/types.ts @@ -48,6 +48,7 @@ export interface QueueStatus { submission?: ChallengeSubmission error?: string position?: number + initialPosition?: number } export interface TaskAttempt { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 61c0ccc..6757c06 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -9,7 +9,7 @@ interface HeaderProps { } export const Header = ({ chainName, taskProgress }: HeaderProps) => { - const { nickname, logout } = useChallenge() + const { nickname, workplaceNumber, logout } = useChallenge() if (!nickname) return null @@ -21,6 +21,14 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => { {chainName || 'Challenge Platform'} + {workplaceNumber && ( + <> + + Место №{workplaceNumber} + + + + )} {nickname} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 548c3e0..63a13a2 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Box, Button, @@ -10,12 +10,41 @@ import { import { useChallenge } from '../context/ChallengeContext' +const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber' + export const LoginForm = () => { + const [step, setStep] = useState<'workplace' | 'fio'>('workplace') + const [workplaceNumber, setWorkplaceNumber] = useState('') const [fullName, setFullName] = useState('') const [error, setError] = useState('') const { login, isAuthLoading } = useChallenge() - const handleSubmit = async (e: React.FormEvent) => { + // Проверяем, есть ли сохранённый номер рабочего места + useEffect(() => { + const savedWorkplace = localStorage.getItem(WORKPLACE_NUMBER_KEY) + if (savedWorkplace) { + setWorkplaceNumber(savedWorkplace) + setStep('fio') + } + }, []) + + const handleWorkplaceSubmit = (e: React.FormEvent) => { + e.preventDefault() + + const trimmedWorkplace = workplaceNumber.trim() + if (!trimmedWorkplace) { + setError('Пожалуйста, введите номер рабочего места') + return + } + + // Сохраняем номер рабочего места сразу + localStorage.setItem(WORKPLACE_NUMBER_KEY, trimmedWorkplace) + setWorkplaceNumber(trimmedWorkplace) + setError('') + setStep('fio') + } + + const handleFioSubmit = async (e: React.FormEvent) => { e.preventDefault() const trimmedName = fullName.trim() @@ -31,13 +60,76 @@ export const LoginForm = () => { try { setError('') - await login(trimmedName) + await login(trimmedName, workplaceNumber.trim()) } catch (err) { setError('Произошла ошибка при входе. Попробуйте снова.') console.error('Login error:', err) } } + if (step === 'workplace') { + return ( + + + {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Challenge Platform + + + Добро пожаловать! Введите номер рабочего места + + + +
+ {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Номер рабочего места + + setWorkplaceNumber(e.target.value)} + placeholder="Например: 1" + size="lg" + autoFocus + // @ts-expect-error Chakra UI v2 uses isInvalid + isInvalid={!!error} + /> + {error && ( + + {error} + + )} + + + + +
+
+
+
+ ) + } + return ( { maxW="480px" w="full" > + {/* @ts-expect-error Chakra UI v2 uses spacing */} Challenge Platform - - Добро пожаловать! Введите ваше ФИО для начала работы + + Рабочее место: №{workplaceNumber} + + + (номер сохранён) + + + Введите ваше ФИО для начала работы -
+ + {/* @ts-expect-error Chakra UI v2 uses spacing */} @@ -71,6 +171,7 @@ export const LoginForm = () => { placeholder="Иванов Иван Иванович" size="lg" autoFocus + // @ts-expect-error Chakra UI v2 uses isInvalid isInvalid={!!error} /> {error && ( @@ -80,15 +181,21 @@ export const LoginForm = () => { )} - + +
@@ -96,4 +203,3 @@ export const LoginForm = () => {
) } - diff --git a/src/components/personal/TaskWorkspace.tsx b/src/components/personal/TaskWorkspace.tsx index d046ed3..fcea615 100644 --- a/src/components/personal/TaskWorkspace.tsx +++ b/src/components/personal/TaskWorkspace.tsx @@ -3,7 +3,6 @@ import { Box, Button, HStack, - Spinner, Text, Textarea, VStack, @@ -33,6 +32,26 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => { const isAccepted = finalSubmission?.status === 'accepted' const needsRevision = finalSubmission?.status === 'needs_revision' + // Вычисляем прогресс проверки (0-100%) + const checkingProgress = (() => { + if (!queueStatus) return 0 + + const initial = queueStatus.initialPosition || 3 + const current = queueStatus.position || 0 + + if (queueStatus.status === 'in_progress') return 90 // Почти готово + if (current === 0) return 90 + + // От 0% до 80% по мере движения в очереди + const progress = ((initial - current) / initial) * 80 + return Math.max(10, progress) // Минимум 10% чтобы было видно + })() + + // Сбрасываем состояние при смене задания + useEffect(() => { + setLastResult(null) + }, [task.id]) + // Обновляем сохраненный результат только когда получаем новый useEffect(() => { if (finalSubmission) { @@ -230,14 +249,59 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => { {/* Статус проверки и результат - фиксированное место */} {queueStatus && !finalSubmission ? ( - - - - - {queueStatus.status === 'waiting' ? 'Ожидание в очереди...' : 'Проверяем решение...'} - {typeof queueStatus.position === 'number' && queueStatus.position > 0 && ` (позиция: ${queueStatus.position})`} - - + + + + + Проверяем решение... + + + + + {/* Кастомный прогресс-бар */} + + + {/* Анимированные полоски */} + + + + + {checkingProgress < 50 ? 'Ожидание в очереди' : checkingProgress < 90 ? 'Начинаем проверку' : 'Почти готово'} + + + ) : showAccepted ? ( diff --git a/src/context/ChallengeContext.tsx b/src/context/ChallengeContext.tsx index 0c3a4a6..1b257a4 100644 --- a/src/context/ChallengeContext.tsx +++ b/src/context/ChallengeContext.tsx @@ -56,13 +56,14 @@ class ChallengeCache { interface ChallengeContextValue { userId: string | null nickname: string | null + workplaceNumber: string | null stats: UserStats | null personalDashboard: PersonalDashboard | null chains: ChallengeChain[] isAuthenticated: boolean isAuthLoading: boolean isStatsLoading: boolean - login: (nickname: string) => Promise + login: (nickname: string, workplaceNumber: string) => Promise logout: () => void refreshStats: () => Promise eventEmitter: ChallengeEventEmitter @@ -78,13 +79,14 @@ const ChallengeContext = createContext(undefi const USER_ID_KEY = 'challengeUserId' const USER_NICKNAME_KEY = 'challengeNickname' +const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber' export const ChallengeProvider = ({ children }: PropsWithChildren) => { const cacheRef = useRef(new ChallengeCache()) const metricsCollector = useMemo(() => new MetricsCollector(), []) const behaviorTracker = useMemo(() => new BehaviorTracker(), []) const eventEmitter = useMemo(() => new ChallengeEventEmitter(), []) - const pollingManager = useMemo(() => new PollingManager(), []) + const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), []) const [userId, setUserId] = useState(() => isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null, @@ -92,6 +94,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { const [nickname, setNickname] = useState(() => isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null, ) + const [workplaceNumber, setWorkplaceNumber] = useState(() => + isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null, + ) const [stats, setStats] = useState(null) const [personalDashboard, setPersonalDashboard] = useState(null) const [chains, setChains] = useState(() => { @@ -151,14 +156,16 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { }, [refreshStats, userId]) const login = useCallback( - async (nicknameValue: string) => { + async (nicknameValue: string, workplaceNumberValue: string) => { const response = await authUser({ nickname: nicknameValue }).unwrap() setUserId(response.userId) setNickname(nicknameValue) + setWorkplaceNumber(workplaceNumberValue) if (isBrowser()) { window.localStorage.setItem(USER_ID_KEY, response.userId) window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue) + window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue) } cacheRef.current.clear('chains') @@ -170,6 +177,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { const logout = useCallback(() => { setUserId(null) setNickname(null) + setWorkplaceNumber(null) setStats(null) setPersonalDashboard(null) cacheRef.current.clear() @@ -177,6 +185,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { if (isBrowser()) { window.localStorage.removeItem(USER_ID_KEY) window.localStorage.removeItem(USER_NICKNAME_KEY) + window.localStorage.removeItem(WORKPLACE_NUMBER_KEY) window.localStorage.removeItem('challengeSelectedChainId') window.localStorage.removeItem('challengeSelectedTaskId') } @@ -188,6 +197,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { () => ({ userId, nickname, + workplaceNumber, stats, personalDashboard, chains, @@ -217,6 +227,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { logout, metricsCollector, nickname, + workplaceNumber, personalDashboard, pollingManager, refreshStats, diff --git a/src/pages/main/main.tsx b/src/pages/main/main.tsx index 5f125b9..eb60aaf 100644 --- a/src/pages/main/main.tsx +++ b/src/pages/main/main.tsx @@ -78,14 +78,19 @@ export const MainPage = () => { const unsubscribe = eventEmitter.on('submission_completed', (event) => { const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission const accepted = submission?.status === 'accepted' - const title = accepted ? 'Задание принято' : 'Задание требует доработки' + + if (!accepted) { + return + } + + const title = 'Задание принято' const description = submission ? `Попытка №${submission.attemptNumber}` : undefined if (notificationTimeoutRef.current) { window.clearTimeout(notificationTimeoutRef.current) } - setNotification({ status: accepted ? 'success' : 'warning', title, description }) + // setNotification({ status: 'success', title, description }) notificationTimeoutRef.current = window.setTimeout(() => setNotification(null), 4000) }) diff --git a/stubs/api/index.js b/stubs/api/index.js index f259838..b03aa43 100644 --- a/stubs/api/index.js +++ b/stubs/api/index.js @@ -2,7 +2,7 @@ const fs = require('fs') const path = require('path') const router = require('express').Router() -const timer = (time = 300) => (req, res, next) => setTimeout(next, time) +const timer = (time = 100) => (req, res, next) => setTimeout(next, time) const dataDir = path.join(__dirname, 'data') @@ -57,18 +57,97 @@ router.get('/challenge/task/:id', (req, res) => { router.post('/challenge/submit', (req, res) => { const response = readJson('submit.json') + const queueId = response.body?.queueId + + if (queueId) { + queueBehaviors[queueId] = queueBehaviors[queueId] ?? { nextFailure: false, attemptNumber: 0 } + queueStates[queueId] = { + position: 3, + initialPosition: 3, + pollCount: 0, + startTime: Date.now(), + } + } + res.json(response) }) +// Храним состояние очереди для каждого queueId +const queueStates = {} +const queueBehaviors = {} + router.get('/challenge/check-status/:queueId', (req, res) => { - const data = readJson('queue-status.json') - const statuses = data.body || data - const status = statuses[req.params.queueId] - - if (!status) { - return sendNotFound(res, `Статус очереди ${req.params.queueId} не найден`) + const queueId = req.params.queueId + + // Инициализируем состояние очереди, если его нет + if (!queueStates[queueId]) { + queueStates[queueId] = { + position: 3, + initialPosition: 3, + pollCount: 0, + startTime: Date.now() + } } + + const state = queueStates[queueId] + state.pollCount++ + + // Симулируем движение в очереди + // Каждый запрос уменьшаем позицию (быстрее) + if (state.pollCount >= 1 && state.position > 0) { + state.position-- + state.pollCount = 0 + } + + // Если позиция 0, переходим к проверке + if (state.position === 0 && state.pollCount >= 1) { + const behavior = queueBehaviors[queueId] ?? { nextFailure: false, attemptNumber: 0 } + const attemptNumber = behavior.attemptNumber + 1 + behavior.attemptNumber = attemptNumber + const shouldFail = behavior.nextFailure + behavior.nextFailure = !shouldFail + const baseSubmission = { + _id: `submission-${queueId}-${attemptNumber}`, + id: `submission-${queueId}-${attemptNumber}`, + user: 'user-frontend-001', + task: 'task-html-intro', + result: '

Hello

', + queueId, + submittedAt: new Date(state.startTime).toISOString(), + checkedAt: new Date().toISOString(), + attemptNumber, + } + + const submission = shouldFail + ? { + ...baseSubmission, + status: 'needs_revision', + feedback: 'Добавьте описание внутри
и поясните, зачем нужен заголовок.', + } + : { + ...baseSubmission, + status: 'accepted', + feedback: 'Отличная работа! Теперь можно двигаться дальше.', + } + + delete queueStates[queueId] + + return res.json({ + success: true, + body: { + status: 'completed', + position: 0, + submission, + }, + }) + } + + // Возвращаем текущее состояние очереди с начальной позицией для расчёта прогресса + const status = state.position > 0 + ? { status: 'waiting', position: state.position, initialPosition: state.initialPosition } + : { status: 'in_progress', position: 0, initialPosition: state.initialPosition } + return res.json({ success: true, body: status }) })