Add rules and patterns for working with Challenge Platform. Introduce guidelines for Chakra UI usage, localStorage management, context updates, code formatting, and multi-step forms. Enhance user experience with a dynamic queue checking system and progress bar. Include a checklist for commits to ensure code quality and consistency.
This commit is contained in:
420
claude.md
Normal file
420
claude.md
Normal file
@@ -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 */}
|
||||||
|
<Button
|
||||||
|
isDisabled={someCondition}
|
||||||
|
>
|
||||||
|
Кнопка
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// ✅ ПРАВИЛЬНО - комментарий перед элементом + все props на одной строке:
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses isDisabled */}
|
||||||
|
<Button isDisabled={someCondition}>Кнопка</Button>
|
||||||
|
|
||||||
|
// ✅ ПРАВИЛЬНО - inline комментарий после prop:
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||||
|
isInvalid={!!error}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Частые props требующие подавления:
|
||||||
|
|
||||||
|
- `isDisabled` (Button, Input)
|
||||||
|
- `isLoading` (Button)
|
||||||
|
- `isInvalid` (Input)
|
||||||
|
- `spacing` (VStack, HStack)
|
||||||
|
|
||||||
|
### VStack и HStack
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ ПРАВИЛЬНО:
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
{/* content */}
|
||||||
|
</VStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💾 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<string | null>(() =>
|
||||||
|
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<string | null>(() =>
|
||||||
|
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<ChallengeContextValue>(
|
||||||
|
() => ({
|
||||||
|
// ... существующие поля
|
||||||
|
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 <Step1Form />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Step2Form />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Важные моменты:
|
||||||
|
1. Сохраняйте данные **сразу** после валидации, не ждите финального submit
|
||||||
|
2. Проверяйте localStorage при монтировании компонента
|
||||||
|
3. Пропускайте шаги, если данные уже есть
|
||||||
|
4. Давайте возможность вернуться и изменить данные
|
||||||
|
|
||||||
|
## 🎨 Header - Паттерн отображения информации
|
||||||
|
|
||||||
|
Порядок отображения в Header:
|
||||||
|
```
|
||||||
|
Место №{number} • {nickname} • {taskProgress}
|
||||||
|
```
|
||||||
|
|
||||||
|
Важные элементы отмечаем `fontWeight="medium"`:
|
||||||
|
```tsx
|
||||||
|
<Text fontSize="sm" color="gray.600" fontWeight="medium">
|
||||||
|
Место №{workplaceNumber}
|
||||||
|
</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Частые ошибки и решения
|
||||||
|
|
||||||
|
### ❌ Ошибка: "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
|
||||||
|
<Box bg="blue.100" borderRadius="md" h="24px" overflow="hidden">
|
||||||
|
<Box
|
||||||
|
bg="blue.500"
|
||||||
|
h="full"
|
||||||
|
w={`${checkingProgress}%`}
|
||||||
|
transition="width 0.5s ease"
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
{/* Анимированный блик */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
bgGradient="linear(to-r, transparent, blue.400, transparent)"
|
||||||
|
animation="shimmer 2s infinite"
|
||||||
|
css={{
|
||||||
|
'@keyframes shimmer': {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(100%)' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Текст под прогресс-баром:**
|
||||||
|
```tsx
|
||||||
|
<Text fontSize="xs" color="blue.600" textAlign="center" mt={2}>
|
||||||
|
{checkingProgress < 50 ? 'Ожидание в очереди' :
|
||||||
|
checkingProgress < 90 ? 'Начинаем проверку' :
|
||||||
|
'Почти готово'}
|
||||||
|
</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Важно
|
||||||
|
|
||||||
|
- **Таймауты в 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 (на основе опыта работы с проектом)
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ export interface QueueStatus {
|
|||||||
submission?: ChallengeSubmission
|
submission?: ChallengeSubmission
|
||||||
error?: string
|
error?: string
|
||||||
position?: number
|
position?: number
|
||||||
|
initialPosition?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskAttempt {
|
export interface TaskAttempt {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
||||||
const { nickname, logout } = useChallenge()
|
const { nickname, workplaceNumber, logout } = useChallenge()
|
||||||
|
|
||||||
if (!nickname) return null
|
if (!nickname) return null
|
||||||
|
|
||||||
@@ -21,6 +21,14 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
|||||||
{chainName || 'Challenge Platform'}
|
{chainName || 'Challenge Platform'}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Flex gap={3} align="center" mt={1}>
|
<Flex gap={3} align="center" mt={1}>
|
||||||
|
{workplaceNumber && (
|
||||||
|
<>
|
||||||
|
<Text fontSize="sm" color="gray.600" fontWeight="medium">
|
||||||
|
Место №{workplaceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.400">•</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Text fontSize="sm" color="gray.600">
|
<Text fontSize="sm" color="gray.600">
|
||||||
{nickname}
|
{nickname}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -10,12 +10,41 @@ import {
|
|||||||
|
|
||||||
import { useChallenge } from '../context/ChallengeContext'
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
|
||||||
|
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
||||||
|
|
||||||
export const LoginForm = () => {
|
export const LoginForm = () => {
|
||||||
|
const [step, setStep] = useState<'workplace' | 'fio'>('workplace')
|
||||||
|
const [workplaceNumber, setWorkplaceNumber] = useState('')
|
||||||
const [fullName, setFullName] = useState('')
|
const [fullName, setFullName] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const { login, isAuthLoading } = useChallenge()
|
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()
|
e.preventDefault()
|
||||||
|
|
||||||
const trimmedName = fullName.trim()
|
const trimmedName = fullName.trim()
|
||||||
@@ -31,13 +60,76 @@ export const LoginForm = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setError('')
|
setError('')
|
||||||
await login(trimmedName)
|
await login(trimmedName, workplaceNumber.trim())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Произошла ошибка при входе. Попробуйте снова.')
|
setError('Произошла ошибка при входе. Попробуйте снова.')
|
||||||
console.error('Login error:', err)
|
console.error('Login error:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (step === 'workplace') {
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={8}
|
||||||
|
maxW="480px"
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Heading size="lg" color="teal.600" mb={2}>
|
||||||
|
Challenge Platform
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600">
|
||||||
|
Добро пожаловать! Введите номер рабочего места
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<form onSubmit={handleWorkplaceSubmit}>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="medium" mb={2}>
|
||||||
|
Номер рабочего места
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={workplaceNumber}
|
||||||
|
onChange={(e) => setWorkplaceNumber(e.target.value)}
|
||||||
|
placeholder="Например: 1"
|
||||||
|
size="lg"
|
||||||
|
autoFocus
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||||
|
isInvalid={!!error}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text color="red.500" fontSize="sm" mt={2}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorScheme="teal"
|
||||||
|
size="lg"
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isDisabled
|
||||||
|
isDisabled={!workplaceNumber.trim()}
|
||||||
|
>
|
||||||
|
Продолжить
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||||
<Box
|
<Box
|
||||||
@@ -49,17 +141,25 @@ export const LoginForm = () => {
|
|||||||
maxW="480px"
|
maxW="480px"
|
||||||
w="full"
|
w="full"
|
||||||
>
|
>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
<Heading size="lg" color="teal.600" mb={2}>
|
<Heading size="lg" color="teal.600" mb={2}>
|
||||||
Challenge Platform
|
Challenge Platform
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text color="gray.600">
|
<Text color="gray.600" fontWeight="medium">
|
||||||
Добро пожаловать! Введите ваше ФИО для начала работы
|
Рабочее место: №{workplaceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray.500" fontSize="sm" mt={1}>
|
||||||
|
(номер сохранён)
|
||||||
|
</Text>
|
||||||
|
<Text color="gray.600" mt={3}>
|
||||||
|
Введите ваше ФИО для начала работы
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleFioSubmit}>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontWeight="medium" mb={2}>
|
<Text fontWeight="medium" mb={2}>
|
||||||
@@ -71,6 +171,7 @@ export const LoginForm = () => {
|
|||||||
placeholder="Иванов Иван Иванович"
|
placeholder="Иванов Иван Иванович"
|
||||||
size="lg"
|
size="lg"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||||
isInvalid={!!error}
|
isInvalid={!!error}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -80,15 +181,21 @@ export const LoginForm = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
||||||
type="submit"
|
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
|
||||||
colorScheme="teal"
|
|
||||||
size="lg"
|
|
||||||
isLoading={isAuthLoading}
|
|
||||||
isDisabled={!fullName.trim()}
|
|
||||||
>
|
|
||||||
Войти
|
Войти
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('workplace')
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Изменить рабочее место
|
||||||
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
</form>
|
</form>
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -96,4 +203,3 @@ export const LoginForm = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
HStack,
|
HStack,
|
||||||
Spinner,
|
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
VStack,
|
VStack,
|
||||||
@@ -33,6 +32,26 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
const isAccepted = finalSubmission?.status === 'accepted'
|
const isAccepted = finalSubmission?.status === 'accepted'
|
||||||
const needsRevision = finalSubmission?.status === 'needs_revision'
|
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(() => {
|
useEffect(() => {
|
||||||
if (finalSubmission) {
|
if (finalSubmission) {
|
||||||
@@ -230,14 +249,59 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
{/* Статус проверки и результат - фиксированное место */}
|
{/* Статус проверки и результат - фиксированное место */}
|
||||||
<Box minH="80px">
|
<Box minH="80px">
|
||||||
{queueStatus && !finalSubmission ? (
|
{queueStatus && !finalSubmission ? (
|
||||||
<Box borderWidth="1px" borderRadius="md" borderColor="blue.200" bg="blue.50" p={2}>
|
<Box
|
||||||
<HStack gap={2}>
|
borderWidth="2px"
|
||||||
<Spinner size="sm" color="blue.500" />
|
borderRadius="lg"
|
||||||
<Text fontSize="sm" fontWeight="medium" color="blue.700">
|
borderColor="blue.300"
|
||||||
{queueStatus.status === 'waiting' ? 'Ожидание в очереди...' : 'Проверяем решение...'}
|
bg="blue.50"
|
||||||
{typeof queueStatus.position === 'number' && queueStatus.position > 0 && ` (позиция: ${queueStatus.position})`}
|
p={4}
|
||||||
</Text>
|
>
|
||||||
</HStack>
|
<VStack gap={3} align="stretch">
|
||||||
|
<HStack justify="center">
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color="blue.700">
|
||||||
|
Проверяем решение...
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
{/* Кастомный прогресс-бар */}
|
||||||
|
<Box
|
||||||
|
bg="blue.100"
|
||||||
|
borderRadius="md"
|
||||||
|
h="24px"
|
||||||
|
overflow="hidden"
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg="blue.500"
|
||||||
|
h="full"
|
||||||
|
w={`${checkingProgress}%`}
|
||||||
|
transition="width 0.5s ease"
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
{/* Анимированные полоски */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
right="0"
|
||||||
|
bottom="0"
|
||||||
|
bgGradient="linear(to-r, transparent, blue.400, transparent)"
|
||||||
|
animation="shimmer 2s infinite"
|
||||||
|
css={{
|
||||||
|
'@keyframes shimmer': {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(100%)' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Text fontSize="xs" color="blue.600" textAlign="center" mt={2} fontWeight="medium">
|
||||||
|
{checkingProgress < 50 ? 'Ожидание в очереди' : checkingProgress < 90 ? 'Начинаем проверку' : 'Почти готово'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
) : showAccepted ? (
|
) : showAccepted ? (
|
||||||
<Box borderWidth="1px" borderRadius="md" borderColor="green.300" bg="green.50" p={3}>
|
<Box borderWidth="1px" borderRadius="md" borderColor="green.300" bg="green.50" p={3}>
|
||||||
|
|||||||
@@ -56,13 +56,14 @@ class ChallengeCache {
|
|||||||
interface ChallengeContextValue {
|
interface ChallengeContextValue {
|
||||||
userId: string | null
|
userId: string | null
|
||||||
nickname: string | null
|
nickname: string | null
|
||||||
|
workplaceNumber: string | null
|
||||||
stats: UserStats | null
|
stats: UserStats | null
|
||||||
personalDashboard: PersonalDashboard | null
|
personalDashboard: PersonalDashboard | null
|
||||||
chains: ChallengeChain[]
|
chains: ChallengeChain[]
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isAuthLoading: boolean
|
isAuthLoading: boolean
|
||||||
isStatsLoading: boolean
|
isStatsLoading: boolean
|
||||||
login: (nickname: string) => Promise<void>
|
login: (nickname: string, workplaceNumber: string) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
refreshStats: () => Promise<void>
|
refreshStats: () => Promise<void>
|
||||||
eventEmitter: ChallengeEventEmitter
|
eventEmitter: ChallengeEventEmitter
|
||||||
@@ -78,13 +79,14 @@ const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefi
|
|||||||
|
|
||||||
const USER_ID_KEY = 'challengeUserId'
|
const USER_ID_KEY = 'challengeUserId'
|
||||||
const USER_NICKNAME_KEY = 'challengeNickname'
|
const USER_NICKNAME_KEY = 'challengeNickname'
|
||||||
|
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
||||||
|
|
||||||
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||||
const cacheRef = useRef(new ChallengeCache())
|
const cacheRef = useRef(new ChallengeCache())
|
||||||
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
||||||
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
|
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
|
||||||
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
|
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<string | null>(() =>
|
const [userId, setUserId] = useState<string | null>(() =>
|
||||||
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
|
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
|
||||||
@@ -92,6 +94,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const [nickname, setNickname] = useState<string | null>(() =>
|
const [nickname, setNickname] = useState<string | null>(() =>
|
||||||
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
|
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
|
||||||
)
|
)
|
||||||
|
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() =>
|
||||||
|
isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null,
|
||||||
|
)
|
||||||
const [stats, setStats] = useState<UserStats | null>(null)
|
const [stats, setStats] = useState<UserStats | null>(null)
|
||||||
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
||||||
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
||||||
@@ -151,14 +156,16 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [refreshStats, userId])
|
}, [refreshStats, userId])
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (nicknameValue: string) => {
|
async (nicknameValue: string, workplaceNumberValue: string) => {
|
||||||
const response = await authUser({ nickname: nicknameValue }).unwrap()
|
const response = await authUser({ nickname: nicknameValue }).unwrap()
|
||||||
setUserId(response.userId)
|
setUserId(response.userId)
|
||||||
setNickname(nicknameValue)
|
setNickname(nicknameValue)
|
||||||
|
setWorkplaceNumber(workplaceNumberValue)
|
||||||
|
|
||||||
if (isBrowser()) {
|
if (isBrowser()) {
|
||||||
window.localStorage.setItem(USER_ID_KEY, response.userId)
|
window.localStorage.setItem(USER_ID_KEY, response.userId)
|
||||||
window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue)
|
window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue)
|
||||||
|
window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheRef.current.clear('chains')
|
cacheRef.current.clear('chains')
|
||||||
@@ -170,6 +177,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
setUserId(null)
|
setUserId(null)
|
||||||
setNickname(null)
|
setNickname(null)
|
||||||
|
setWorkplaceNumber(null)
|
||||||
setStats(null)
|
setStats(null)
|
||||||
setPersonalDashboard(null)
|
setPersonalDashboard(null)
|
||||||
cacheRef.current.clear()
|
cacheRef.current.clear()
|
||||||
@@ -177,6 +185,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
if (isBrowser()) {
|
if (isBrowser()) {
|
||||||
window.localStorage.removeItem(USER_ID_KEY)
|
window.localStorage.removeItem(USER_ID_KEY)
|
||||||
window.localStorage.removeItem(USER_NICKNAME_KEY)
|
window.localStorage.removeItem(USER_NICKNAME_KEY)
|
||||||
|
window.localStorage.removeItem(WORKPLACE_NUMBER_KEY)
|
||||||
window.localStorage.removeItem('challengeSelectedChainId')
|
window.localStorage.removeItem('challengeSelectedChainId')
|
||||||
window.localStorage.removeItem('challengeSelectedTaskId')
|
window.localStorage.removeItem('challengeSelectedTaskId')
|
||||||
}
|
}
|
||||||
@@ -188,6 +197,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
() => ({
|
() => ({
|
||||||
userId,
|
userId,
|
||||||
nickname,
|
nickname,
|
||||||
|
workplaceNumber,
|
||||||
stats,
|
stats,
|
||||||
personalDashboard,
|
personalDashboard,
|
||||||
chains,
|
chains,
|
||||||
@@ -217,6 +227,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
logout,
|
logout,
|
||||||
metricsCollector,
|
metricsCollector,
|
||||||
nickname,
|
nickname,
|
||||||
|
workplaceNumber,
|
||||||
personalDashboard,
|
personalDashboard,
|
||||||
pollingManager,
|
pollingManager,
|
||||||
refreshStats,
|
refreshStats,
|
||||||
|
|||||||
@@ -78,14 +78,19 @@ export const MainPage = () => {
|
|||||||
const unsubscribe = eventEmitter.on('submission_completed', (event) => {
|
const unsubscribe = eventEmitter.on('submission_completed', (event) => {
|
||||||
const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission
|
const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission
|
||||||
const accepted = submission?.status === 'accepted'
|
const accepted = submission?.status === 'accepted'
|
||||||
const title = accepted ? 'Задание принято' : 'Задание требует доработки'
|
|
||||||
|
if (!accepted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = 'Задание принято'
|
||||||
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
|
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
|
||||||
|
|
||||||
if (notificationTimeoutRef.current) {
|
if (notificationTimeoutRef.current) {
|
||||||
window.clearTimeout(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)
|
notificationTimeoutRef.current = window.setTimeout(() => setNotification(null), 4000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const fs = require('fs')
|
|||||||
const path = require('path')
|
const path = require('path')
|
||||||
const router = require('express').Router()
|
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')
|
const dataDir = path.join(__dirname, 'data')
|
||||||
|
|
||||||
@@ -57,18 +57,97 @@ router.get('/challenge/task/:id', (req, res) => {
|
|||||||
|
|
||||||
router.post('/challenge/submit', (req, res) => {
|
router.post('/challenge/submit', (req, res) => {
|
||||||
const response = readJson('submit.json')
|
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)
|
res.json(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Храним состояние очереди для каждого queueId
|
||||||
|
const queueStates = {}
|
||||||
|
const queueBehaviors = {}
|
||||||
|
|
||||||
router.get('/challenge/check-status/:queueId', (req, res) => {
|
router.get('/challenge/check-status/:queueId', (req, res) => {
|
||||||
const data = readJson('queue-status.json')
|
const queueId = req.params.queueId
|
||||||
const statuses = data.body || data
|
|
||||||
const status = statuses[req.params.queueId]
|
// Инициализируем состояние очереди, если его нет
|
||||||
|
if (!queueStates[queueId]) {
|
||||||
if (!status) {
|
queueStates[queueId] = {
|
||||||
return sendNotFound(res, `Статус очереди ${req.params.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: '<html><head></head><body><h1>Hello</h1></body></html>',
|
||||||
|
queueId,
|
||||||
|
submittedAt: new Date(state.startTime).toISOString(),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
attemptNumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
const submission = shouldFail
|
||||||
|
? {
|
||||||
|
...baseSubmission,
|
||||||
|
status: 'needs_revision',
|
||||||
|
feedback: 'Добавьте описание внутри <section> и поясните, зачем нужен заголовок.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...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 })
|
return res.json({ success: true, body: status })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user