421 lines
15 KiB
Markdown
421 lines
15 KiB
Markdown
# Правила работы с 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 (на основе опыта работы с проектом)
|
||
|