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

View File

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

View File

@@ -9,7 +9,7 @@ interface HeaderProps {
}
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
const { nickname, logout } = useChallenge()
const { nickname, workplaceNumber, logout } = useChallenge()
if (!nickname) return null
@@ -21,6 +21,14 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
{chainName || 'Challenge Platform'}
</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>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import {
Box,
Button,
@@ -10,12 +10,41 @@ import {
import { useChallenge } from '../context/ChallengeContext'
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
export const LoginForm = () => {
const [step, setStep] = useState<'workplace' | 'fio'>('workplace')
const [workplaceNumber, setWorkplaceNumber] = useState('')
const [fullName, setFullName] = useState('')
const [error, setError] = useState('')
const { login, isAuthLoading } = useChallenge()
const handleSubmit = async (e: React.FormEvent) => {
// Проверяем, есть ли сохранённый номер рабочего места
useEffect(() => {
const savedWorkplace = localStorage.getItem(WORKPLACE_NUMBER_KEY)
if (savedWorkplace) {
setWorkplaceNumber(savedWorkplace)
setStep('fio')
}
}, [])
const handleWorkplaceSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmedWorkplace = workplaceNumber.trim()
if (!trimmedWorkplace) {
setError('Пожалуйста, введите номер рабочего места')
return
}
// Сохраняем номер рабочего места сразу
localStorage.setItem(WORKPLACE_NUMBER_KEY, trimmedWorkplace)
setWorkplaceNumber(trimmedWorkplace)
setError('')
setStep('fio')
}
const handleFioSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedName = fullName.trim()
@@ -31,13 +60,76 @@ export const LoginForm = () => {
try {
setError('')
await login(trimmedName)
await login(trimmedName, workplaceNumber.trim())
} catch (err) {
setError('Произошла ошибка при входе. Попробуйте снова.')
console.error('Login error:', err)
}
}
if (step === 'workplace') {
return (
<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 (
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
@@ -49,17 +141,25 @@ export const LoginForm = () => {
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 color="gray.600" fontWeight="medium">
Рабочее место: {workplaceNumber}
</Text>
<Text color="gray.500" fontSize="sm" mt={1}>
(номер сохранён)
</Text>
<Text color="gray.600" mt={3}>
Введите ваше ФИО для начала работы
</Text>
</Box>
<form onSubmit={handleSubmit}>
<form onSubmit={handleFioSubmit}>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
@@ -71,6 +171,7 @@ export const LoginForm = () => {
placeholder="Иванов Иван Иванович"
size="lg"
autoFocus
// @ts-expect-error Chakra UI v2 uses isInvalid
isInvalid={!!error}
/>
{error && (
@@ -80,15 +181,21 @@ export const LoginForm = () => {
)}
</Box>
<Button
type="submit"
colorScheme="teal"
size="lg"
isLoading={isAuthLoading}
isDisabled={!fullName.trim()}
>
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
Войти
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setStep('workplace')
setError('')
}}
>
Изменить рабочее место
</Button>
</VStack>
</form>
</VStack>
@@ -96,4 +203,3 @@ export const LoginForm = () => {
</Box>
)
}

View File

@@ -3,7 +3,6 @@ import {
Box,
Button,
HStack,
Spinner,
Text,
Textarea,
VStack,
@@ -33,6 +32,26 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
const isAccepted = finalSubmission?.status === 'accepted'
const needsRevision = finalSubmission?.status === 'needs_revision'
// Вычисляем прогресс проверки (0-100%)
const checkingProgress = (() => {
if (!queueStatus) return 0
const initial = queueStatus.initialPosition || 3
const current = queueStatus.position || 0
if (queueStatus.status === 'in_progress') return 90 // Почти готово
if (current === 0) return 90
// От 0% до 80% по мере движения в очереди
const progress = ((initial - current) / initial) * 80
return Math.max(10, progress) // Минимум 10% чтобы было видно
})()
// Сбрасываем состояние при смене задания
useEffect(() => {
setLastResult(null)
}, [task.id])
// Обновляем сохраненный результат только когда получаем новый
useEffect(() => {
if (finalSubmission) {
@@ -230,14 +249,59 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
{/* Статус проверки и результат - фиксированное место */}
<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}>

View File

@@ -56,13 +56,14 @@ class ChallengeCache {
interface ChallengeContextValue {
userId: string | null
nickname: string | null
workplaceNumber: string | null
stats: UserStats | null
personalDashboard: PersonalDashboard | null
chains: ChallengeChain[]
isAuthenticated: boolean
isAuthLoading: boolean
isStatsLoading: boolean
login: (nickname: string) => Promise<void>
login: (nickname: string, workplaceNumber: string) => Promise<void>
logout: () => void
refreshStats: () => Promise<void>
eventEmitter: ChallengeEventEmitter
@@ -78,13 +79,14 @@ const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefi
const USER_ID_KEY = 'challengeUserId'
const USER_NICKNAME_KEY = 'challengeNickname'
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
const cacheRef = useRef(new ChallengeCache())
const metricsCollector = useMemo(() => new MetricsCollector(), [])
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
const pollingManager = useMemo(() => new PollingManager(), [])
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), [])
const [userId, setUserId] = useState<string | null>(() =>
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
@@ -92,6 +94,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
const [nickname, setNickname] = useState<string | 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 [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
const [chains, setChains] = useState<ChallengeChain[]>(() => {
@@ -151,14 +156,16 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
}, [refreshStats, userId])
const login = useCallback(
async (nicknameValue: string) => {
async (nicknameValue: string, workplaceNumberValue: string) => {
const response = await authUser({ nickname: nicknameValue }).unwrap()
setUserId(response.userId)
setNickname(nicknameValue)
setWorkplaceNumber(workplaceNumberValue)
if (isBrowser()) {
window.localStorage.setItem(USER_ID_KEY, response.userId)
window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue)
window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue)
}
cacheRef.current.clear('chains')
@@ -170,6 +177,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
const logout = useCallback(() => {
setUserId(null)
setNickname(null)
setWorkplaceNumber(null)
setStats(null)
setPersonalDashboard(null)
cacheRef.current.clear()
@@ -177,6 +185,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
if (isBrowser()) {
window.localStorage.removeItem(USER_ID_KEY)
window.localStorage.removeItem(USER_NICKNAME_KEY)
window.localStorage.removeItem(WORKPLACE_NUMBER_KEY)
window.localStorage.removeItem('challengeSelectedChainId')
window.localStorage.removeItem('challengeSelectedTaskId')
}
@@ -188,6 +197,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
() => ({
userId,
nickname,
workplaceNumber,
stats,
personalDashboard,
chains,
@@ -217,6 +227,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
logout,
metricsCollector,
nickname,
workplaceNumber,
personalDashboard,
pollingManager,
refreshStats,

View File

@@ -78,14 +78,19 @@ export const MainPage = () => {
const unsubscribe = eventEmitter.on('submission_completed', (event) => {
const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission
const accepted = submission?.status === 'accepted'
const title = accepted ? 'Задание принято' : 'Задание требует доработки'
if (!accepted) {
return
}
const title = 'Задание принято'
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
if (notificationTimeoutRef.current) {
window.clearTimeout(notificationTimeoutRef.current)
}
setNotification({ status: accepted ? 'success' : 'warning', title, description })
// setNotification({ status: 'success', title, description })
notificationTimeoutRef.current = window.setTimeout(() => setNotification(null), 4000)
})