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:
2025-12-13 19:23:39 +03:00
parent d5b54138bb
commit 259b1c9353
8 changed files with 730 additions and 36 deletions

420
claude.md Normal file
View 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 (на основе опыта работы с проектом)

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>
) )
} }

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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)
}) })

View File

@@ -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 })
}) })