10 Commits

Author SHA1 Message Date
08b654bd4d 1.2.0
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:17:40 +03:00
cbf411cd54 Implement final answer management in submission hook and storage utilities. Add functions to save, load, and clear final answers in localStorage. Update useSubmission hook to prioritize final answers over drafts, enhancing user experience during task submissions.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:17:30 +03:00
f4e85fe980 Update LoginPage layout for improved spacing and enhance storage utility with new clearAllChainProgress function. Adjust clearAll method to retain workplace number while ensuring all chain progress data is removed upon logout.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:12:32 +03:00
3c1a235832 Fix color scheme logic in TaskPage component to ensure consistent button styling based on task accessibility. Simplified the color scheme condition for better readability and maintainability.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:08:04 +03:00
1a52901b90 Enhance task navigation and progress tracking across components. Update TaskWorkspace to improve task completion handling and button interactions. Refactor ChainsPage to select the furthest accessible task based on user progress. Implement storage functions for managing furthest task indices, ensuring users can only navigate to available tasks. Update TaskPage to check task accessibility and redirect users appropriately, improving overall user experience.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:06:18 +03:00
c9bbe83bbb Refactor Dashboard component to implement a structured routing system with dedicated pages for workplace input, login, chain selection, task management, and completion. Introduce centralized localStorage management for user data and navigation logic, enhancing user experience and streamlining the application flow. Remove the deprecated LoginForm component and update the MainPage to redirect users based on their authentication and task status.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 13:58:24 +03:00
9f5a236c7c 1.1.0
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-13 19:30:01 +03:00
dac2ba4078 Update task progress display in MainPage component to remove total task count from the string. This simplifies the information presented to the user while maintaining clarity on the current task index.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-13 19:28:55 +03:00
f774cd27d8 Implement completion screen in MainPage component to celebrate task achievements. Introduce state management for completed chains and enhance UI with congratulatory messages and a continue button. Remove unused notification logic to streamline the component.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-13 19:27:25 +03:00
259b1c9353 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. 2025-12-13 19:23:39 +03:00
27 changed files with 1670 additions and 309 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 (на основе опыта работы с проектом)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "challenge",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "challenge",
"version": "1.0.0",
"version": "1.2.0",
"license": "ISC",
"dependencies": {
"@brojs/cli": "^1.9.4",

View File

@@ -1,6 +1,6 @@
{
"name": "challenge",
"version": "1.0.0",
"version": "1.2.0",
"description": "",
"main": "./src/index.tsx",
"scripts": {

View File

@@ -48,6 +48,7 @@ export interface QueueStatus {
submission?: ChallengeSubmission
error?: string
position?: number
initialPosition?: number
}
export interface TaskAttempt {

View File

@@ -17,6 +17,14 @@ const getNavPath = (key: string, fallback: string) => {
export const URLs = {
baseUrl,
// Основные маршруты
workplace: makeUrl('/workplace'),
login: makeUrl('/login'),
chains: makeUrl('/chains'),
chain: (chainId: string) => makeUrl(`/chain/${chainId}`),
task: (chainId: string, taskId: string) => makeUrl(`/chain/${chainId}/task/${taskId}`),
completed: (chainId: string) => makeUrl(`/completed/${chainId}`),
// Старые маршруты
auth: {
url: makeUrl(navs[`link.${pkg.name}.auth`]),
isOn: Boolean(navs[`link.${pkg.name}.auth`]),

View File

@@ -1,7 +1,9 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
import { useChallenge } from '../context/ChallengeContext'
import { URLs } from '../__data__/urls'
interface HeaderProps {
chainName?: string
@@ -9,7 +11,13 @@ interface HeaderProps {
}
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
const { nickname, logout } = useChallenge()
const navigate = useNavigate()
const { nickname, workplaceNumber, logout } = useChallenge()
const handleLogout = () => {
logout()
navigate(URLs.workplace)
}
if (!nickname) return null
@@ -21,6 +29,14 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
{chainName || 'Challenge Platform'}
</Heading>
<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">
{nickname}
</Text>
@@ -34,7 +50,7 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
)}
</Flex>
</Box>
<Button onClick={logout} variant="ghost" size="sm">
<Button onClick={handleLogout} variant="ghost" size="sm">
Выйти
</Button>
</Flex>

View File

@@ -1,99 +0,0 @@
import React, { useState } from 'react'
import {
Box,
Button,
Heading,
Input,
Text,
VStack,
} from '@chakra-ui/react'
import { useChallenge } from '../context/ChallengeContext'
export const LoginForm = () => {
const [fullName, setFullName] = useState('')
const [error, setError] = useState('')
const { login, isAuthLoading } = useChallenge()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedName = fullName.trim()
if (!trimmedName) {
setError('Пожалуйста, введите ваше ФИО')
return
}
if (trimmedName.length < 3) {
setError('ФИО должно содержать минимум 3 символа')
return
}
try {
setError('')
await login(trimmedName)
} catch (err) {
setError('Произошла ошибка при входе. Попробуйте снова.')
console.error('Login error:', err)
}
}
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"
>
<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={handleSubmit}>
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
Ваше ФИО
</Text>
<Input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Иванов Иван Иванович"
size="lg"
autoFocus
isInvalid={!!error}
/>
{error && (
<Text color="red.500" fontSize="sm" mt={2}>
{error}
</Text>
)}
</Box>
<Button
type="submit"
colorScheme="teal"
size="lg"
isLoading={isAuthLoading}
isDisabled={!fullName.trim()}
>
Войти
</Button>
</VStack>
</form>
</VStack>
</Box>
</Box>
)
}

View File

@@ -3,7 +3,6 @@ import {
Box,
Button,
HStack,
Spinner,
Text,
Textarea,
VStack,
@@ -22,7 +21,7 @@ interface TaskWorkspaceProps {
export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
const { refreshStats } = useChallenge()
const { result, setResult, submit, reset, queueStatus, finalSubmission, isSubmitting } = useSubmission({
const { result, setResult, submit, queueStatus, finalSubmission, isSubmitting } = useSubmission({
taskId: task.id,
})
@@ -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) => {
{/* Статус проверки и результат - фиксированное место */}
<Box minH="80px">
{queueStatus && !finalSubmission ? (
<Box borderWidth="1px" borderRadius="md" borderColor="blue.200" bg="blue.50" p={2}>
<HStack gap={2}>
<Spinner size="sm" color="blue.500" />
<Text fontSize="sm" fontWeight="medium" color="blue.700">
{queueStatus.status === 'waiting' ? 'Ожидание в очереди...' : 'Проверяем решение...'}
{typeof queueStatus.position === 'number' && queueStatus.position > 0 && ` (позиция: ${queueStatus.position})`}
</Text>
</HStack>
<Box
borderWidth="2px"
borderRadius="lg"
borderColor="blue.300"
bg="blue.50"
p={4}
>
<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>
) : showAccepted ? (
<Box borderWidth="1px" borderRadius="md" borderColor="green.300" bg="green.50" p={3}>
@@ -300,12 +364,18 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
/>
</Box>
<HStack justify="flex-end" gap={2}>
<HStack justify="space-between" gap={2}>
{!isAccepted && (
<>
{/* @ts-expect-error Chakra UI v2 uses isDisabled */}
<Button onClick={reset} variant="ghost" size="sm" isDisabled={isChecking}>
Сбросить
<Button
onClick={onTaskComplete}
variant="outline"
size="sm"
colorScheme="gray"
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={isChecking}
>
Пропустить
</Button>
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
<Button onClick={submit} colorScheme="teal" size="sm" isLoading={isChecking} isDisabled={!result.trim() || isChecking}>

View File

@@ -19,8 +19,7 @@ import { BehaviorTracker, MetricsCollector, buildPersonalDashboard } from '../ut
import { ChallengeEventEmitter } from '../utils/events'
import { clearDraft, loadDraft, saveDraft } from '../utils/drafts'
import { PollingManager } from '../utils/polling'
const isBrowser = () => typeof window !== 'undefined'
import { storage } from '../utils/storage'
class ChallengeCache {
private cache = new Map<string, { data: unknown; expires: number }>()
@@ -56,13 +55,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<void>
login: (nickname: string, workplaceNumber: string) => Promise<void>
logout: () => void
refreshStats: () => Promise<void>
eventEmitter: ChallengeEventEmitter
@@ -76,22 +76,16 @@ interface ChallengeContextValue {
const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefined)
const USER_ID_KEY = 'challengeUserId'
const USER_NICKNAME_KEY = 'challengeNickname'
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<string | null>(() =>
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
)
const [nickname, setNickname] = useState<string | null>(() =>
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
)
const [userId, setUserId] = useState<string | null>(() => storage.getUserId())
const [nickname, setNickname] = useState<string | null>(() => storage.getNickname())
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() => storage.getWorkplaceNumber())
const [stats, setStats] = useState<UserStats | null>(null)
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
const [chains, setChains] = useState<ChallengeChain[]>(() => {
@@ -151,15 +145,15 @@ 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)
}
storage.setUserId(response.userId)
storage.setNickname(nicknameValue)
storage.setWorkplaceNumber(workplaceNumberValue)
cacheRef.current.clear('chains')
await refreshStatsById(response.userId)
@@ -170,16 +164,13 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
const logout = useCallback(() => {
setUserId(null)
setNickname(null)
setWorkplaceNumber(null)
setStats(null)
setPersonalDashboard(null)
cacheRef.current.clear()
if (isBrowser()) {
window.localStorage.removeItem(USER_ID_KEY)
window.localStorage.removeItem(USER_NICKNAME_KEY)
window.localStorage.removeItem('challengeSelectedChainId')
window.localStorage.removeItem('challengeSelectedTaskId')
}
// Очищаем всё из localStorage
storage.clearAll()
}, [])
const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading
@@ -188,6 +179,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
() => ({
userId,
nickname,
workplaceNumber,
stats,
personalDashboard,
chains,
@@ -217,6 +209,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
logout,
metricsCollector,
nickname,
workplaceNumber,
personalDashboard,
pollingManager,
refreshStats,

View File

@@ -1,24 +1,107 @@
import React, { Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { Navigate, Route, Routes } from 'react-router-dom'
import { URLs } from './__data__/urls'
import { MainPage } from './pages'
import {
WorkplacePage,
LoginPage,
ChainsPage,
TaskPage,
CompletedPage
} from './pages'
import { storage } from './utils/storage'
const PageWrapper = ({ children }: React.PropsWithChildren) => (
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
)
// Компонент для редиректа на нужную страницу
const IndexRedirect = () => {
const workplaceNumber = storage.getWorkplaceNumber()
const nickname = storage.getNickname()
const chainId = storage.getSelectedChainId()
const taskId = storage.getSelectedTaskId()
// Если есть сохранённое задание - туда
if (nickname && chainId && taskId) {
return <Navigate to={URLs.task(chainId, taskId)} replace />
}
// Если авторизован - к цепочкам
if (nickname) {
return <Navigate to={URLs.chains} replace />
}
// Если есть номер места - к логину
if (workplaceNumber) {
return <Navigate to={URLs.login} replace />
}
// Иначе - к вводу места
return <Navigate to={URLs.workplace} replace />
}
export const Dashboard = () => {
return (
<Routes>
{/* Главная - редирект */}
<Route
path={URLs.baseUrl}
element={<IndexRedirect />}
/>
{/* Ввод номера рабочего места */}
<Route
path={URLs.workplace}
element={
<PageWrapper>
<MainPage />
<WorkplacePage />
</PageWrapper>
}
/>
{/* Ввод ФИО */}
<Route
path={URLs.login}
element={
<PageWrapper>
<LoginPage />
</PageWrapper>
}
/>
{/* Выбор цепочки */}
<Route
path={URLs.chains}
element={
<PageWrapper>
<ChainsPage />
</PageWrapper>
}
/>
{/* Задание */}
<Route
path={`${URLs.baseUrl}/chain/:chainId/task/:taskId`}
element={
<PageWrapper>
<TaskPage />
</PageWrapper>
}
/>
{/* Завершение цепочки */}
<Route
path={`${URLs.baseUrl}/completed/:chainId`}
element={
<PageWrapper>
<CompletedPage />
</PageWrapper>
}
/>
{/* Fallback */}
<Route
path="*"
element={<IndexRedirect />}
/>
</Routes>
)
}

View File

@@ -6,6 +6,7 @@ import {
} from '../__data__/api/api'
import type { ChallengeSubmission, QueueStatus } from '../__data__/types'
import { useChallenge } from '../context/ChallengeContext'
import { loadFinalAnswer, saveFinalAnswer } from '../utils/drafts'
interface UseSubmissionArgs {
taskId: string
@@ -43,13 +44,24 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
useEffect(() => {
behaviorTracker.reset()
const draft = loadDraft(taskId)
if (draft) {
setResultState(draft)
// Сначала проверяем финальный ответ (если задание уже решалось)
const finalAnswer = loadFinalAnswer(taskId)
if (finalAnswer) {
setResultState(finalAnswer)
behaviorTracker.markDraftUsed()
} else {
setResultState('')
// Если финального ответа нет, проверяем черновик
const draft = loadDraft(taskId)
if (draft) {
setResultState(draft)
behaviorTracker.markDraftUsed()
} else {
// Если ничего нет - пустое поле
setResultState('')
}
}
pollingManager.stop()
setQueueId(null)
setQueueStatus(null)
@@ -114,6 +126,9 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
})
setFinalSubmission(status.submission)
// Сохраняем финальный ответ для восстановления при возврате
saveFinalAnswer(taskId, result)
// Очищаем черновик, так как теперь есть финальный ответ
clearDraft(taskId)
pollingManager.stop()
return false

View File

@@ -0,0 +1,57 @@
import React, { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { URLs } from '../../__data__/urls'
import { useChallenge } from '../../context/ChallengeContext'
import { Header } from '../../components/Header'
import { ChainSelector } from '../../components/ChainSelector'
import { storage } from '../../utils/storage'
import type { ChallengeChain } from '../../__data__/types'
export const ChainsPage = () => {
const navigate = useNavigate()
const { nickname } = useChallenge()
// Проверяем авторизацию
useEffect(() => {
const workplaceNumber = storage.getWorkplaceNumber()
if (!workplaceNumber) {
navigate(URLs.workplace, { replace: true })
return
}
if (!nickname) {
navigate(URLs.login, { replace: true })
}
}, [navigate, nickname])
const handleSelectChain = (chain: ChallengeChain) => {
storage.setSelectedChainId(chain.id)
if (chain.tasks.length > 0) {
// Получаем самый дальний достигнутый индекс
const furthestIndex = storage.getFurthestTaskIndex(chain.id)
// Если нет прогресса, инициализируем с первого задания
const targetIndex = furthestIndex >= 0 ? furthestIndex : 0
const targetTask = chain.tasks[targetIndex] || chain.tasks[0]
storage.setSelectedTaskId(targetTask.id)
// Убеждаемся, что прогресс установлен
storage.setFurthestTaskIndex(chain.id, targetIndex)
navigate(URLs.task(chain.id, targetTask.id))
}
}
if (!nickname) {
return null
}
return (
<>
<Header />
<ChainSelector onSelectChain={handleSelectChain} />
</>
)
}

View File

@@ -0,0 +1,4 @@
import { ChainsPage } from './ChainsPage'
export default ChainsPage

View File

@@ -0,0 +1,99 @@
import React, { useEffect, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Box,
Button,
Heading,
Text,
VStack,
} from '@chakra-ui/react'
import { URLs } from '../../__data__/urls'
import { useChallenge } from '../../context/ChallengeContext'
import { Header } from '../../components/Header'
import { storage } from '../../utils/storage'
export const CompletedPage = () => {
const navigate = useNavigate()
const { chainId } = useParams<{ chainId: string }>()
const { nickname, chains } = useChallenge()
// Проверяем авторизацию
useEffect(() => {
const workplaceNumber = storage.getWorkplaceNumber()
if (!workplaceNumber) {
navigate(URLs.workplace, { replace: true })
return
}
if (!nickname) {
navigate(URLs.login, { replace: true })
}
}, [navigate, nickname])
const chain = useMemo(() => {
return chains.find(c => c.id === chainId) || null
}, [chains, chainId])
const handleContinue = () => {
navigate(URLs.chains)
}
if (!nickname) {
return null
}
return (
<>
<Header />
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="2px"
borderRadius="xl"
borderColor="green.300"
p={10}
maxW="600px"
w="full"
textAlign="center"
shadow="lg"
>
<VStack gap={6}>
<Text fontSize="6xl">🎉</Text>
<Heading size="xl" color="green.600">
Поздравляем!
</Heading>
<Text fontSize="lg" color="gray.700">
Вы успешно выполнили все задания
</Text>
{chain && (
<Box
bg="green.50"
borderRadius="lg"
px={6}
py={3}
borderWidth="1px"
borderColor="green.200"
>
<Text fontSize="xl" fontWeight="bold" color="green.700">
{chain.name}
</Text>
</Box>
)}
<Text fontSize="md" color="gray.600">
Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий.
</Text>
<Button
colorScheme="green"
size="lg"
onClick={handleContinue}
mt={4}
>
Продолжить
</Button>
</VStack>
</Box>
</Box>
</>
)
}

View File

@@ -0,0 +1,4 @@
import { CompletedPage } from './CompletedPage'
export default CompletedPage

View File

@@ -1,3 +1,8 @@
import { lazy } from 'react'
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
export const WorkplacePage = lazy(() => import(/* webpackChunkName: 'workplace' */'./workplace'))
export const LoginPage = lazy(() => import(/* webpackChunkName: 'login' */'./login'))
export const ChainsPage = lazy(() => import(/* webpackChunkName: 'chains' */'./chains'))
export const TaskPage = lazy(() => import(/* webpackChunkName: 'task' */'./task'))
export const CompletedPage = lazy(() => import(/* webpackChunkName: 'completed' */'./completed'))

View File

@@ -0,0 +1,141 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Button,
Heading,
Input,
Text,
VStack,
} from '@chakra-ui/react'
import { URLs } from '../../__data__/urls'
import { useChallenge } from '../../context/ChallengeContext'
import { storage } from '../../utils/storage'
export const LoginPage = () => {
const navigate = useNavigate()
const { login, isAuthLoading, nickname } = useChallenge()
const [fullName, setFullName] = useState('')
const [error, setError] = useState('')
const workplaceNumber = storage.getWorkplaceNumber()
// Если нет номера рабочего места, возвращаемся
useEffect(() => {
if (!workplaceNumber) {
navigate(URLs.workplace, { replace: true })
return
}
// Если уже авторизован, переходим к цепочкам
if (nickname) {
navigate(URLs.chains, { replace: true })
}
}, [navigate, workplaceNumber, nickname])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedName = fullName.trim()
if (!trimmedName) {
setError('Пожалуйста, введите ваше ФИО')
return
}
if (trimmedName.length < 3) {
setError('ФИО должно содержать минимум 3 символа')
return
}
try {
setError('')
await login(trimmedName, workplaceNumber || '')
navigate(URLs.chains)
} catch (err) {
setError('Произошла ошибка при входе. Попробуйте снова.')
console.error('Login error:', err)
}
}
const handleChangeWorkplace = () => {
storage.removeWorkplaceNumber()
navigate(URLs.workplace)
}
if (!workplaceNumber) {
return null
}
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" fontWeight="medium">
Рабочее место: {workplaceNumber}
</Text>
<Text color="gray.600" mt={3}>
Введите ваше ФИО для начала работы
</Text>
</Box>
<form onSubmit={handleSubmit}>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
Ваше ФИО
</Text>
<Input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Иванов Иван Иванович"
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>
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
Войти
</Button>
</VStack>
</form>
<Box textAlign="center" mt={120}>
<Text
as="button"
type="button"
color="gray.500"
fontSize="sm"
cursor="pointer"
_hover={{ color: 'teal.600', textDecoration: 'underline' }}
onClick={handleChangeWorkplace}
>
Изменить рабочее место
</Text>
</Box>
</VStack>
</Box>
</Box>
)
}

4
src/pages/login/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { LoginPage } from './LoginPage'
export default LoginPage

View File

@@ -1,160 +1,36 @@
import React, { useEffect, useRef, useState } from 'react'
import {
Box,
Text,
} from '@chakra-ui/react'
import { Alert } from '@chakra-ui/react/alert'
import React from 'react'
import { Navigate } from 'react-router-dom'
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
import { useChallenge } from '../../context/ChallengeContext'
import { TaskWorkspace } from '../../components/personal'
import { Header } from '../../components/Header'
import { LoginForm } from '../../components/LoginForm'
import { ChainSelector } from '../../components/ChainSelector'
const SELECTED_CHAIN_KEY = 'challengeSelectedChainId'
const SELECTED_TASK_KEY = 'challengeSelectedTaskId'
import { URLs } from '../../__data__/urls'
import { storage } from '../../utils/storage'
/**
* MainPage теперь просто редиректит на нужную страницу
* Вся логика навигации находится в отдельных страницах:
* - /workplace - ввод номера рабочего места
* - /login - ввод ФИО
* - /chains - выбор цепочки
* - /chain/:chainId/task/:taskId - задание
* - /completed/:chainId - завершение цепочки
*/
export const MainPage = () => {
const { nickname, eventEmitter, chains } = useChallenge()
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
const [isOffline, setIsOffline] = useState(() =>
typeof navigator !== 'undefined' ? !navigator.onLine : false,
)
const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null)
const notificationTimeoutRef = useRef<number | null>(null)
const hasRestoredState = useRef(false)
const workplaceNumber = storage.getWorkplaceNumber()
const nickname = storage.getNickname()
const chainId = storage.getSelectedChainId()
const taskId = storage.getSelectedTaskId()
// Восстановление состояния при загрузке
useEffect(() => {
if (hasRestoredState.current || !chains.length || !nickname) return
const savedChainId = localStorage.getItem(SELECTED_CHAIN_KEY)
const savedTaskId = localStorage.getItem(SELECTED_TASK_KEY)
if (savedChainId) {
const chain = chains.find(c => c.id === savedChainId)
if (chain) {
setSelectedChain(chain)
if (savedTaskId) {
const task = chain.tasks.find(t => t.id === savedTaskId)
setSelectedTask(task || chain.tasks[0])
} else {
setSelectedTask(chain.tasks[0])
}
}
}
hasRestoredState.current = true
}, [chains, nickname])
const handleSelectChain = (chain: ChallengeChain) => {
setSelectedChain(chain)
setSelectedTask(chain.tasks[0])
localStorage.setItem(SELECTED_CHAIN_KEY, chain.id)
localStorage.setItem(SELECTED_TASK_KEY, chain.tasks[0].id)
// Если есть сохранённое задание - туда
if (nickname && chainId && taskId) {
return <Navigate to={URLs.task(chainId, taskId)} replace />
}
const handleTaskComplete = () => {
if (!selectedChain) return
const currentIndex = selectedChain.tasks.findIndex((item) => item.id === selectedTask?.id)
const nextTask = currentIndex >= 0 ? selectedChain.tasks[currentIndex + 1] : null
if (nextTask) {
setSelectedTask(nextTask)
localStorage.setItem(SELECTED_TASK_KEY, nextTask.id)
} else {
// Цепочка завершена, возвращаемся к выбору
setSelectedChain(null)
setSelectedTask(null)
localStorage.removeItem(SELECTED_CHAIN_KEY)
localStorage.removeItem(SELECTED_TASK_KEY)
}
// Если авторизован - к цепочкам
if (nickname) {
return <Navigate to={URLs.chains} replace />
}
useEffect(() => {
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 ? 'Задание принято' : 'Задание требует доработки'
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
if (notificationTimeoutRef.current) {
window.clearTimeout(notificationTimeoutRef.current)
}
setNotification({ status: accepted ? 'success' : 'warning', title, description })
notificationTimeoutRef.current = window.setTimeout(() => setNotification(null), 4000)
})
return () => {
unsubscribe()
if (notificationTimeoutRef.current) {
window.clearTimeout(notificationTimeoutRef.current)
}
}
}, [eventEmitter])
useEffect(() => {
const handleOnline = () => setIsOffline(false)
const handleOffline = () => setIsOffline(true)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
// Если пользователь не авторизован, показываем форму входа
if (!nickname) {
return <LoginForm />
// Если есть номер места - к логину
if (workplaceNumber) {
return <Navigate to={URLs.login} replace />
}
// Если цепочка не выбрана, показываем селектор цепочек
if (!selectedChain) {
return (
<>
<Header />
<ChainSelector onSelectChain={handleSelectChain} />
</>
)
}
const taskProgress = `Задание ${selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1} из ${selectedChain.tasks.length}`
// Показываем выбранную цепочку и задания
return (
<>
<Header chainName={selectedChain.name} taskProgress={taskProgress} />
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
<Box maxW="1200px" mx="auto">
{notification && (
<Alert.Root status={notification.status} borderRadius="md" mb={4}>
<Alert.Indicator />
<Box ml={3}>
<Text fontWeight="semibold">{notification.title}</Text>
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
</Box>
</Alert.Root>
)}
{isOffline && (
<Alert.Root status="warning" borderRadius="md" mb={4}>
<Alert.Indicator />
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
</Alert.Root>
)}
{selectedTask && (
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
)}
</Box>
</Box>
</>
)
// Иначе - к вводу места
return <Navigate to={URLs.workplace} replace />
}

203
src/pages/task/TaskPage.tsx Normal file
View File

@@ -0,0 +1,203 @@
import React, { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Box,
Button,
Flex,
HStack,
Text,
} from '@chakra-ui/react'
import { URLs } from '../../__data__/urls'
import { useChallenge } from '../../context/ChallengeContext'
import { TaskWorkspace } from '../../components/personal'
import { Header } from '../../components/Header'
import { storage } from '../../utils/storage'
export const TaskPage = () => {
const navigate = useNavigate()
const { chainId, taskId } = useParams<{ chainId: string; taskId: string }>()
const { nickname, chains } = useChallenge()
// Проверяем авторизацию
useEffect(() => {
const workplaceNumber = storage.getWorkplaceNumber()
if (!workplaceNumber) {
navigate(URLs.workplace, { replace: true })
return
}
if (!nickname) {
navigate(URLs.login, { replace: true })
}
}, [navigate, nickname])
// Находим цепочку и задание
const chain = useMemo(() => {
return chains.find(c => c.id === chainId) || null
}, [chains, chainId])
const task = useMemo(() => {
return chain?.tasks.find(t => t.id === taskId) || null
}, [chain, taskId])
const currentTaskIndex = useMemo(() => {
if (!chain || !taskId) return -1
return chain.tasks.findIndex(t => t.id === taskId)
}, [chain, taskId])
// Получаем самый дальний достигнутый индекс задания (используем state для реактивности)
const [furthestTaskIndex, setFurthestTaskIndex] = useState(() => {
if (!chainId) return 0
return storage.getFurthestTaskIndex(chainId)
})
// Обновляем furthestTaskIndex при изменении chainId или currentTaskIndex
useEffect(() => {
if (!chainId) return
const currentFurthest = storage.getFurthestTaskIndex(chainId)
setFurthestTaskIndex(currentFurthest)
}, [chainId, currentTaskIndex])
// Сохраняем текущее состояние в storage и обновляем прогресс
useEffect(() => {
if (chainId && taskId && currentTaskIndex >= 0) {
storage.setSelectedChainId(chainId)
storage.setSelectedTaskId(taskId)
// Обновляем прогресс, если текущее задание дальше предыдущего
const newFurthest = Math.max(furthestTaskIndex, currentTaskIndex)
if (newFurthest > furthestTaskIndex) {
storage.setFurthestTaskIndex(chainId, newFurthest)
setFurthestTaskIndex(newFurthest)
}
}
}, [chainId, taskId, currentTaskIndex, furthestTaskIndex])
// Проверка доступности задания
const isTaskAccessible = (taskIndex: number): boolean => {
return taskIndex <= furthestTaskIndex
}
const handleTaskComplete = () => {
if (!chain || currentTaskIndex === -1) return
const nextTaskIndex = currentTaskIndex + 1
const nextTask = chain.tasks[nextTaskIndex]
if (nextTask) {
// Обновляем прогресс перед переходом
storage.setFurthestTaskIndex(chain.id, nextTaskIndex)
setFurthestTaskIndex(nextTaskIndex) // Обновляем state сразу
navigate(URLs.task(chain.id, nextTask.id))
} else {
// Цепочка завершена
storage.clearSessionData()
navigate(URLs.completed(chain.id))
}
}
const handleNavigateToTask = (newTaskId: string) => {
if (!chain) return
const newTaskIndex = chain.tasks.findIndex(t => t.id === newTaskId)
if (newTaskIndex === -1) return
// Проверяем доступность
if (!isTaskAccessible(newTaskIndex)) {
return // Не переходим к заблокированному заданию
}
// Обновляем прогресс при переходе
const newFurthest = Math.max(furthestTaskIndex, newTaskIndex)
if (newFurthest > furthestTaskIndex) {
storage.setFurthestTaskIndex(chain.id, newFurthest)
setFurthestTaskIndex(newFurthest)
}
navigate(URLs.task(chain.id, newTaskId))
}
// Проверяем доступность текущего задания при загрузке
useEffect(() => {
if (chain && currentTaskIndex >= 0 && !isTaskAccessible(currentTaskIndex)) {
// Если пытаемся открыть недоступное задание, перенаправляем на последнее доступное
const lastAccessibleIndex = furthestTaskIndex
if (lastAccessibleIndex >= 0 && chain.tasks[lastAccessibleIndex]) {
navigate(URLs.task(chain.id, chain.tasks[lastAccessibleIndex].id), { replace: true })
} else if (chain.tasks[0]) {
// Если нет прогресса, идём к первому заданию
navigate(URLs.task(chain.id, chain.tasks[0].id), { replace: true })
}
}
}, [chain, currentTaskIndex, furthestTaskIndex, navigate])
const handleBackToChains = () => {
storage.clearSessionData()
navigate(URLs.chains)
}
if (!nickname || !chain || !task) {
return null
}
const taskProgress = `Задание ${currentTaskIndex + 1}`
return (
<>
<Header chainName={chain.name} taskProgress={taskProgress} />
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
<Box maxW="1200px" mx="auto">
{/* Навигация по заданиям */}
<Box
bg="white"
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
p={3}
mb={4}
shadow="sm"
>
<Flex justify="space-between" align="center" flexWrap="wrap" gap={3}>
<HStack gap={2} flexWrap="wrap">
<Text fontSize="sm" fontWeight="medium" color="gray.600" mr={2}>
Задания:
</Text>
{chain.tasks.map((t, index) => {
const isAccessible = isTaskAccessible(index)
const isCurrent = t.id === taskId
return (
<Button
key={t.id}
size="sm"
variant={isCurrent ? 'solid' : isAccessible ? 'outline' : 'ghost'}
colorScheme={isCurrent ? 'teal' : 'gray'}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={!isAccessible}
onClick={() => isAccessible && handleNavigateToTask(t.id)}
minW="40px"
opacity={isAccessible ? 1 : 0.5}
cursor={isAccessible ? 'pointer' : 'not-allowed'}
>
{isAccessible ? index + 1 : `🔒${index + 1}`}
</Button>
)
})}
</HStack>
<Button
size="sm"
variant="ghost"
colorScheme="gray"
onClick={handleBackToChains}
>
К выбору цепочки
</Button>
</Flex>
</Box>
<TaskWorkspace task={task} onTaskComplete={handleTaskComplete} />
</Box>
</Box>
</>
)
}

4
src/pages/task/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { TaskPage } from './TaskPage'
export default TaskPage

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Button,
Heading,
Input,
Link,
Text,
VStack,
} from '@chakra-ui/react'
import { URLs } from '../../__data__/urls'
import { storage } from '../../utils/storage'
// Список полезных ссылок
const USEFUL_LINKS = [
{ url: 'https://ya.ru', label: 'Яндекс' },
{ url: 'https://giga.chat', label: 'GigaChat' },
]
export const WorkplacePage = () => {
const navigate = useNavigate()
const [workplaceNumber, setWorkplaceNumber] = useState('')
const [error, setError] = useState('')
// Если номер уже есть, переходим дальше
useEffect(() => {
const saved = storage.getWorkplaceNumber()
if (saved) {
// Проверяем, авторизован ли пользователь
const nickname = storage.getNickname()
if (nickname) {
navigate(URLs.chains, { replace: true })
} else {
navigate(URLs.login, { replace: true })
}
}
}, [navigate])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = workplaceNumber.trim()
if (!trimmed) {
setError('Пожалуйста, введите номер рабочего места')
return
}
storage.setWorkplaceNumber(trimmed)
navigate(URLs.login)
}
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={handleSubmit}>
{/* @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>
{/* Полезные ссылки */}
<Box
borderWidth="1px"
borderRadius="md"
borderColor="gray.200"
p={4}
bg="gray.50"
>
<Text fontWeight="medium" mb={3} fontSize="sm">
Полезные ссылки:
</Text>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={2} align="stretch">
{USEFUL_LINKS.map(link => (
<Link
key={link.url}
href={link.url}
target="_blank"
color="teal.600"
fontSize="sm"
fontWeight="medium"
_hover={{ color: 'teal.800', textDecoration: 'underline' }}
>
🔗 {link.label}
</Link>
))}
</VStack>
</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>
)
}

View File

@@ -0,0 +1,4 @@
import { WorkplacePage } from './WorkplacePage'
export default WorkplacePage

View File

@@ -1,4 +1,5 @@
const STORAGE_PREFIX = 'challenge_draft_'
const FINAL_ANSWER_PREFIX = 'challenge_final_answer_'
const isBrowser = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
@@ -17,6 +18,24 @@ export function clearDraft(taskId: string) {
window.localStorage.removeItem(`${STORAGE_PREFIX}${taskId}`)
}
// Сохранение финального ответа (после успешной отправки)
export function saveFinalAnswer(taskId: string, result: string) {
if (!isBrowser()) return
window.localStorage.setItem(`${FINAL_ANSWER_PREFIX}${taskId}`, result)
}
// Загрузка финального ответа
export function loadFinalAnswer(taskId: string): string | null {
if (!isBrowser()) return null
return window.localStorage.getItem(`${FINAL_ANSWER_PREFIX}${taskId}`)
}
// Очистка финального ответа
export function clearFinalAnswer(taskId: string) {
if (!isBrowser()) return
window.localStorage.removeItem(`${FINAL_ANSWER_PREFIX}${taskId}`)
}
export function listDrafts() {
if (!isBrowser()) return [] as string[]
@@ -30,3 +49,16 @@ export function listDrafts() {
return keys
}
// Очистка всех финальных ответов (при выходе)
export function clearAllFinalAnswers() {
if (!isBrowser()) return
const keysToRemove: string[] = []
for (let i = 0; i < window.localStorage.length; i += 1) {
const key = window.localStorage.key(i)
if (key?.startsWith(FINAL_ANSWER_PREFIX)) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => window.localStorage.removeItem(key))
}

165
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Централизованная работа с localStorage
* Все ключи и операции в одном месте
*/
import { clearAllFinalAnswers, listDrafts, clearDraft } from './drafts'
const isBrowser = () => typeof window !== 'undefined'
// Ключи localStorage
export const STORAGE_KEYS = {
USER_ID: 'challengeUserId',
NICKNAME: 'challengeNickname',
WORKPLACE_NUMBER: 'challengeWorkplaceNumber',
SELECTED_CHAIN_ID: 'challengeSelectedChainId',
SELECTED_TASK_ID: 'challengeSelectedTaskId',
} as const
// Вспомогательная функция для ключа прогресса цепочки
const getFurthestTaskKey = (chainId: string) => `challengeFurthestTask_${chainId}`
// Получение значений
export const storage = {
getUserId: (): string | null => {
if (!isBrowser()) return null
return localStorage.getItem(STORAGE_KEYS.USER_ID)
},
getNickname: (): string | null => {
if (!isBrowser()) return null
return localStorage.getItem(STORAGE_KEYS.NICKNAME)
},
getWorkplaceNumber: (): string | null => {
if (!isBrowser()) return null
return localStorage.getItem(STORAGE_KEYS.WORKPLACE_NUMBER)
},
getSelectedChainId: (): string | null => {
if (!isBrowser()) return null
return localStorage.getItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
},
getSelectedTaskId: (): string | null => {
if (!isBrowser()) return null
return localStorage.getItem(STORAGE_KEYS.SELECTED_TASK_ID)
},
// Установка значений
setUserId: (value: string): void => {
if (!isBrowser()) return
localStorage.setItem(STORAGE_KEYS.USER_ID, value)
},
setNickname: (value: string): void => {
if (!isBrowser()) return
localStorage.setItem(STORAGE_KEYS.NICKNAME, value)
},
setWorkplaceNumber: (value: string): void => {
if (!isBrowser()) return
localStorage.setItem(STORAGE_KEYS.WORKPLACE_NUMBER, value)
},
setSelectedChainId: (value: string): void => {
if (!isBrowser()) return
localStorage.setItem(STORAGE_KEYS.SELECTED_CHAIN_ID, value)
},
setSelectedTaskId: (value: string): void => {
if (!isBrowser()) return
localStorage.setItem(STORAGE_KEYS.SELECTED_TASK_ID, value)
},
// Удаление значений
removeUserId: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.USER_ID)
},
removeNickname: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.NICKNAME)
},
removeWorkplaceNumber: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.WORKPLACE_NUMBER)
},
removeSelectedChainId: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
},
removeSelectedTaskId: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
},
// Полная очистка при выходе (кроме номера рабочего места)
clearAll: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.USER_ID)
localStorage.removeItem(STORAGE_KEYS.NICKNAME)
// Номер рабочего места НЕ удаляем
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
// Очищаем все прогрессы по цепочкам
storage.clearAllChainProgress()
// Очищаем все финальные ответы
clearAllFinalAnswers()
// Очищаем все черновики
const drafts = listDrafts()
drafts.forEach(taskId => clearDraft(taskId))
},
// Очистка всех прогрессов по цепочкам
clearAllChainProgress: (): void => {
if (!isBrowser()) return
// Перебираем все ключи localStorage и удаляем те, что начинаются с challengeFurthestTask_
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith('challengeFurthestTask_')) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
},
// Очистка данных сессии (цепочка, задание) без выхода
clearSessionData: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
},
// Получение самого дальнего достигнутого индекса задания в цепочке
getFurthestTaskIndex: (chainId: string): number => {
if (!isBrowser()) return 0
const value = localStorage.getItem(getFurthestTaskKey(chainId))
return value ? parseInt(value, 10) : 0
},
// Установка самого дальнего достигнутого индекса задания
setFurthestTaskIndex: (chainId: string, index: number): void => {
if (!isBrowser()) return
const current = storage.getFurthestTaskIndex(chainId)
// Обновляем только если новый индекс больше текущего
if (index > current) {
localStorage.setItem(getFurthestTaskKey(chainId), index.toString())
}
},
// Очистка прогресса цепочки
clearChainProgress: (chainId: string): void => {
if (!isBrowser()) return
localStorage.removeItem(getFurthestTaskKey(chainId))
},
}

View File

@@ -23,6 +23,38 @@
"description": "# React компонент\n\nСоздайте компонент `StatCard` с пропсами `title` и `value`.",
"createdAt": "2024-09-05T11:30:00.000Z",
"updatedAt": "2024-10-01T09:45:00.000Z"
},
{
"id": "task-css-layout",
"_id": "task-css-layout",
"title": "CSS-верстка",
"description": "# CSS-верстка\n\nСверстайте карточку с заголовком, описанием и кнопкой. Используйте Flexbox и тени.",
"createdAt": "2024-09-10T14:00:00.000Z",
"updatedAt": "2024-10-05T09:00:00.000Z"
},
{
"id": "task-js-dom",
"_id": "task-js-dom",
"title": "DOM-манипуляции",
"description": "# DOM-манипуляции\n\nНапишите скрипт, который добавляет элементы списка по клику на кнопку и очищает их по второй кнопке.",
"createdAt": "2024-09-15T10:20:00.000Z",
"updatedAt": "2024-10-07T12:30:00.000Z"
},
{
"id": "task-react-state",
"_id": "task-react-state",
"title": "Состояние React",
"description": "# Состояние React\n\nСоздайте компонент, который показывает счётчик кликов и два кнопки — увеличить/сбросить. Используйте хуки.",
"createdAt": "2024-09-20T09:15:00.000Z",
"updatedAt": "2024-10-08T08:45:00.000Z"
},
{
"id": "task-api-fetch",
"_id": "task-api-fetch",
"title": "Работа с API",
"description": "# Работа с API\n\nСделайте запрос к фиктивному API и отобразите список пользователей с именами и email.",
"createdAt": "2024-09-25T13:40:00.000Z",
"updatedAt": "2024-10-09T10:10:00.000Z"
}
]
}

View File

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