Add detailed statistics API v2 documentation and implement frontend components for displaying statistics

This commit is contained in:
Primakov Alexandr Alexandrovich
2025-11-04 21:37:03 +03:00
parent b91ee56bf0
commit fd55d5a214
16 changed files with 2233 additions and 29 deletions

View File

@@ -8,6 +8,7 @@ import type {
ChallengeUser,
ChallengeSubmission,
SystemStats,
SystemStatsV2,
UserStats,
CreateTaskRequest,
UpdateTaskRequest,
@@ -125,6 +126,14 @@ export const api = createApi({
transformResponse: (response: { body: SystemStats }) => response.body,
providesTags: ['Stats'],
}),
getSystemStatsV2: builder.query<SystemStatsV2, string | undefined>({
query: (chainId) => ({
url: '/challenge/stats/v2',
params: chainId ? { chainId } : undefined,
}),
transformResponse: (response: { body: SystemStatsV2 }) => response.body,
providesTags: ['Stats'],
}),
getUserStats: builder.query<UserStats, string>({
query: (userId) => `/challenge/user/${userId}/stats`,
transformResponse: (response: { body: UserStats }) => response.body,
@@ -161,6 +170,7 @@ export const {
useDeleteChainMutation,
useGetUsersQuery,
useGetSystemStatsQuery,
useGetSystemStatsV2Query,
useGetUserStatsQuery,
useGetUserSubmissionsQuery,
useGetAllSubmissionsQuery,

View File

@@ -12,6 +12,11 @@ export const URLs = {
// Dashboard
dashboard: makeUrl(''),
// Detailed Stats
detailedStats: makeUrl('/detailed-stats'),
detailedStatsChain: (chainId: string) => makeUrl(`/detailed-stats/${chainId}`),
detailedStatsChainPath: makeUrl('/detailed-stats/:chainId'),
// Tasks
tasks: makeUrl('/tasks'),
taskNew: makeUrl('/tasks/new'),

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Box, Container, Flex, HStack, VStack, Button, Text } from '@chakra-ui/react'
import { Box, Container, Flex, HStack, Button, Text } from '@chakra-ui/react'
import { useAppSelector } from '../__data__/store'
import { URLs } from '../__data__/urls'
import { keycloak } from '../__data__/kc'
@@ -34,6 +34,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
const navItems = [
{ label: t('challenge.admin.layout.nav.dashboard'), path: URLs.dashboard },
{ label: t('challenge.admin.layout.nav.detailed.stats'), path: URLs.detailedStats },
{ label: t('challenge.admin.layout.nav.tasks'), path: URLs.tasks },
{ label: t('challenge.admin.layout.nav.chains'), path: URLs.chains },
{ label: t('challenge.admin.layout.nav.users'), path: URLs.users },
@@ -106,16 +107,15 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
<Container maxW="container.xl">
<HStack gap={1} py={2}>
{navItems.map((item) => (
<Button
key={item.path}
as={Link}
to={item.path}
size="sm"
variant={isActive(item.path) ? 'solid' : 'ghost'}
colorPalette={isActive(item.path) ? 'teal' : 'gray'}
>
{item.label}
</Button>
<Link key={item.path} to={item.path} style={{ textDecoration: 'none' }}>
<Button
size="sm"
variant={isActive(item.path) ? 'solid' : 'ghost'}
colorPalette={isActive(item.path) ? 'teal' : 'gray'}
>
{item.label}
</Button>
</Link>
))}
</HStack>
</Container>

View File

@@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom'
import { Layout } from './components/Layout'
import { DashboardPage } from './pages/dashboard/DashboardPage'
import { DetailedStatsPage } from './pages/detailed-stats/DetailedStatsPage'
import { TasksListPage } from './pages/tasks/TasksListPage'
import { TaskFormPage } from './pages/tasks/TaskFormPage'
import { ChainsListPage } from './pages/chains/ChainsListPage'
@@ -30,6 +31,24 @@ export const Dashboard = () => {
}
/>
{/* Detailed Stats */}
<Route
path={URLs.detailedStats}
element={
<PageWrapper>
<DetailedStatsPage />
</PageWrapper>
}
/>
<Route
path={URLs.detailedStatsChainPath}
element={
<PageWrapper>
<DetailedStatsPage />
</PageWrapper>
}
/>
{/* Tasks */}
<Route
path={URLs.tasks}

View File

@@ -0,0 +1,160 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Box, Heading, Text, Table, Badge } from '@chakra-ui/react'
import type { ChainDetailed, TaskProgressStatus } from '../../types/challenge'
interface ChainDetailedViewProps {
chains: ChainDetailed[]
}
interface StatusConfig {
label: string
color: 'gray' | 'yellow' | 'blue' | 'orange' | 'green'
}
const getStatusConfig = (status: TaskProgressStatus, t: (key: string) => string): StatusConfig => {
const configs: Record<TaskProgressStatus, StatusConfig> = {
not_started: { label: t('challenge.admin.detailed.stats.status.not.started'), color: 'gray' },
pending: { label: t('challenge.admin.detailed.stats.status.pending'), color: 'yellow' },
in_progress: { label: t('challenge.admin.detailed.stats.status.in.progress'), color: 'blue' },
needs_revision: { label: t('challenge.admin.detailed.stats.status.needs.revision'), color: 'orange' },
completed: { label: t('challenge.admin.detailed.stats.status.completed'), color: 'green' },
}
return configs[status]
}
export const ChainDetailedView: React.FC<ChainDetailedViewProps> = ({ chains }) => {
const { t } = useTranslation()
if (chains.length === 0) {
return (
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={4}>
{t('challenge.admin.detailed.stats.chains.title')}
</Heading>
<Box color="gray.500" textAlign="center" py={8}>
{t('challenge.admin.detailed.stats.chains.empty')}
</Box>
</Box>
)
}
const chain = chains[0] // Теперь всегда одна цепочка из API
return (
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={4}>
{t('challenge.admin.detailed.stats.chains.title')}
</Heading>
<Box mb={3}>
<Heading size="sm" color="teal.600" mb={1}>
{chain.name}
</Heading>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.chains.total.tasks')} {chain.totalTasks}
</Text>
</Box>
{chain.participantProgress.length > 0 ? (
<Box overflowX="auto">
<Table.Root size="sm" variant="outline">
<Table.Header>
<Table.Row bg="gray.50">
<Table.ColumnHeader
position="sticky"
left={0}
bg="gray.50"
zIndex={1}
minW="150px"
borderRight="1px"
borderColor="gray.200"
>
{t('challenge.admin.detailed.stats.chains.participant')}
</Table.ColumnHeader>
{chain.tasks.map((task) => (
<Table.ColumnHeader
key={task.taskId}
textAlign="center"
minW="120px"
maxW="200px"
>
<Text fontSize="xs" lineClamp={2} title={task.title}>
{task.title}
</Text>
</Table.ColumnHeader>
))}
<Table.ColumnHeader
textAlign="center"
minW="100px"
borderLeft="1px"
borderColor="gray.200"
fontWeight="bold"
>
{t('challenge.admin.detailed.stats.chains.progress')}
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{chain.participantProgress.map((participant) => (
<Table.Row key={participant.userId}>
<Table.Cell
position="sticky"
left={0}
bg="white"
zIndex={1}
fontWeight="medium"
borderRight="1px"
borderColor="gray.200"
>
{participant.nickname}
</Table.Cell>
{participant.taskProgress.map((taskProg) => {
const config = getStatusConfig(taskProg.status, t)
return (
<Table.Cell key={taskProg.taskId} textAlign="center">
<Badge
colorPalette={config.color}
size="sm"
title={taskProg.taskTitle}
>
{config.label}
</Badge>
</Table.Cell>
)
})}
<Table.Cell
textAlign="center"
borderLeft="1px"
borderColor="gray.200"
fontWeight="bold"
>
<Badge
colorPalette={
participant.progressPercent >= 80
? 'green'
: participant.progressPercent >= 50
? 'yellow'
: 'red'
}
>
{participant.progressPercent}%
</Badge>
<Text fontSize="xs" color="gray.600" mt={1}>
{participant.completedCount}/{chain.totalTasks}
</Text>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
) : (
<Box color="gray.500" textAlign="center" py={4} bg="gray.50" borderRadius="md">
{t('challenge.admin.detailed.stats.chains.no.participants')}
</Box>
)}
</Box>
)
}

View File

@@ -0,0 +1,240 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, Link } from 'react-router-dom'
import { Box, Heading, VStack, Text, HStack, Badge, SimpleGrid } from '@chakra-ui/react'
import { useGetChainsQuery, useGetSystemStatsV2Query } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { URLs } from '../../__data__/urls'
import { TasksStatisticsTable } from './TasksStatisticsTable'
import { ParticipantsProgress } from './ParticipantsProgress'
import { ChainDetailedView } from './ChainDetailedView'
export const DetailedStatsPage: React.FC = () => {
const { t } = useTranslation()
const { chainId } = useParams<{ chainId?: string }>()
// Получаем список цепочек
const { data: chains, isLoading: isChainsLoading, error: chainsError } = useGetChainsQuery()
// Получаем детальную статистику по выбранной цепочке (только если chainId есть)
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch } = useGetSystemStatsV2Query(
chainId,
{
pollingInterval: 5000, // Обновление каждые 5 секунд для реального времени
skip: !chainId, // Не делаем запрос пока не выбрана цепочка
}
)
const isLoading = isChainsLoading || (chainId && isStatsLoading)
const error = chainsError || statsError
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.detailed.stats.loading')} />
}
if (error) {
return (
<ErrorAlert
message={t('challenge.admin.detailed.stats.load.error')}
onRetry={refetch}
/>
)
}
// Если chainId не указан - показываем карточки для выбора
if (!chainId) {
return (
<Box>
<Box mb={6}>
<Heading mb={2}>{t('challenge.admin.detailed.stats.title')}</Heading>
<Text color="gray.600" fontSize="sm">
{t('challenge.admin.detailed.stats.select.chain')}
</Text>
</Box>
{chains && chains.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={6}>
{chains.map((chain) => (
<Link key={chain.id} to={URLs.detailedStatsChain(chain.id)} style={{ textDecoration: 'none' }}>
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
_hover={{
boxShadow: 'md',
borderColor: 'teal.400',
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
cursor="pointer"
height="100%"
>
<VStack align="start" gap={3}>
<Heading size="md" color="teal.600">
{chain.name}
</Heading>
<HStack>
<Badge colorPalette="teal" size="lg">
{chain.tasks.length} {t('challenge.admin.detailed.stats.chain.card.tasks')}
</Badge>
</HStack>
<Text fontSize="sm" color="gray.600" mt={2}>
{t('challenge.admin.detailed.stats.chain.card.click')}
</Text>
</VStack>
</Box>
</Link>
))}
</SimpleGrid>
) : (
<Box color="gray.500" textAlign="center" py={8}>
{t('challenge.admin.detailed.stats.no.chains')}
</Box>
)}
</Box>
)
}
// Если chainId указан но данных еще нет - ждем загрузки
if (!stats) {
return null
}
const acceptanceRate = stats.submissions.total > 0
? ((stats.submissions.accepted / stats.submissions.total) * 100).toFixed(1)
: '0'
const selectedChain = chains?.find(c => c.id === chainId)
return (
<Box>
<Box mb={6}>
<HStack justify="space-between" align="start" mb={2}>
<Box>
<HStack gap={2} mb={2}>
<Link to={URLs.detailedStats} style={{ textDecoration: 'none', color: '#319795' }}>
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }}>
{t('challenge.admin.detailed.stats.back.to.chains')}
</Text>
</Link>
</HStack>
<Heading mb={2}>
{selectedChain?.name || t('challenge.admin.detailed.stats.title')}
</Heading>
<Text color="gray.600" fontSize="sm">
{t('challenge.admin.detailed.stats.auto.refresh')}
</Text>
</Box>
</HStack>
</Box>
{/* Quick Stats Overview */}
<Box
bg="white"
p={6}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
mb={6}
>
<Heading size="sm" mb={4}>
{t('challenge.admin.detailed.stats.overview.title')}
</Heading>
<HStack wrap="wrap" gap={6}>
<VStack align="start" gap={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.overview.users')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.users}
</Text>
</VStack>
<VStack align="start" gap={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.overview.tasks')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="teal.600">
{stats.tasks}
</Text>
</VStack>
<VStack align="start" gap={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.overview.chains')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="purple.600">
{stats.chains}
</Text>
</VStack>
<VStack align="start" gap={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.overview.total.attempts')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.submissions.total}
</Text>
</VStack>
<VStack align="start" gap={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.overview.successful')}
</Text>
<HStack>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.submissions.accepted}
</Text>
<Badge colorPalette="green" size="lg">
{acceptanceRate}%
</Badge>
</HStack>
</VStack>
<VStack align="start" gap={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.overview.in.progress.pending')}
</Text>
<HStack>
<Badge colorPalette="blue" size="lg">
{stats.submissions.inProgress}
</Badge>
<Badge colorPalette="yellow" size="lg">
{stats.submissions.pending}
</Badge>
</HStack>
</VStack>
<VStack align="start" gap={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.overview.avg.check.time')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="purple.600">
{(stats.averageCheckTimeMs / 1000).toFixed(1)}с
</Text>
</VStack>
</HStack>
</Box>
{/* Main Content - Three Sections */}
{stats && (
<VStack align="stretch" gap={6}>
{/* 1. Tasks Statistics Table */}
<TasksStatisticsTable tasks={stats.tasksTable} />
{/* 2. Active Participants Progress */}
<ParticipantsProgress participants={stats.activeParticipants} />
{/* 3. Chain Detailed View */}
<ChainDetailedView chains={stats.chainsDetailed} />
</VStack>
)}
</Box>
)
}

View File

@@ -0,0 +1,110 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Box, Heading, Grid, Text, VStack, HStack, Progress } from '@chakra-ui/react'
import type { ActiveParticipant } from '../../types/challenge'
interface ParticipantsProgressProps {
participants: ActiveParticipant[]
}
export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ participants }) => {
const { t } = useTranslation()
const getProgressColor = (percent: number) => {
if (percent >= 80) return 'green'
if (percent >= 50) return 'yellow'
return 'red'
}
if (participants.length === 0) {
return (
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={4}>
{t('challenge.admin.detailed.stats.participants.title')}
</Heading>
<Box color="gray.500" textAlign="center" py={8}>
{t('challenge.admin.detailed.stats.participants.empty')}
</Box>
</Box>
)
}
return (
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={4}>
{t('challenge.admin.detailed.stats.participants.title')}
</Heading>
<Grid templateColumns="repeat(auto-fill, minmax(350px, 1fr))" gap={4}>
{participants.map((participant) => (
<Box
key={participant.userId}
p={4}
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
bg="gray.50"
_hover={{ borderColor: 'teal.300', boxShadow: 'md' }}
transition="all 0.2s"
>
<VStack align="stretch" gap={3}>
{/* Participant Header */}
<Box>
<Text fontSize="lg" fontWeight="bold" color="teal.700">
{participant.nickname}
</Text>
<HStack gap={4} mt={1}>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.participants.completed')} <Text as="span" fontWeight="bold" color="green.600">
{participant.completedTasks}
</Text>
</Text>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.detailed.stats.participants.attempts')} <Text as="span" fontWeight="bold" color="blue.600">
{participant.totalSubmissions}
</Text>
</Text>
</HStack>
</Box>
{/* Chain Progress */}
{participant.chainProgress.length > 0 ? (
<VStack align="stretch" gap={3} mt={2}>
{participant.chainProgress.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium" color="gray.700">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks}/{chain.totalTasks}
</Text>
</HStack>
<Progress.Root
value={chain.progressPercent}
colorPalette={getProgressColor(chain.progressPercent)}
size="sm"
borderRadius="full"
>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
<Text fontSize="xs" color="gray.500" mt={1} textAlign="right">
{chain.progressPercent}%
</Text>
</Box>
))}
</VStack>
) : (
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
{t('challenge.admin.detailed.stats.participants.no.progress')}
</Text>
)}
</VStack>
</Box>
))}
</Grid>
</Box>
)
}

View File

@@ -0,0 +1,148 @@
import React, { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Box, Heading, Table, Badge } from '@chakra-ui/react'
import type { TaskTableItem } from '../../types/challenge'
interface TasksStatisticsTableProps {
tasks: TaskTableItem[]
}
type SortKey = keyof TaskTableItem | null
type SortDirection = 'asc' | 'desc'
export const TasksStatisticsTable: React.FC<TasksStatisticsTableProps> = ({ tasks }) => {
const { t } = useTranslation()
const [sortKey, setSortKey] = useState<SortKey>('successRate')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDirection('desc')
}
}
const sortedTasks = useMemo(() => {
if (!sortKey) return tasks
return [...tasks].sort((a, b) => {
const aVal = a[sortKey]
const bVal = b[sortKey]
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDirection === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal
}
return 0
})
}, [tasks, sortKey, sortDirection])
const getSuccessRateColor = (rate: number) => {
if (rate >= 80) return 'green'
if (rate >= 50) return 'yellow'
return 'red'
}
if (tasks.length === 0) {
return (
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={4}>
{t('challenge.admin.detailed.stats.tasks.table.title')}
</Heading>
<Box color="gray.500" textAlign="center" py={8}>
{t('challenge.admin.detailed.stats.tasks.table.empty')}
</Box>
</Box>
)
}
return (
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={4}>
{t('challenge.admin.detailed.stats.tasks.table.title')}
</Heading>
<Box overflowX="auto">
<Table.Root size="sm" variant="line">
<Table.Header>
<Table.Row>
<Table.ColumnHeader
cursor="pointer"
onClick={() => handleSort('title')}
_hover={{ bg: 'gray.50' }}
>
{t('challenge.admin.detailed.stats.tasks.table.task.name')} {sortKey === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</Table.ColumnHeader>
<Table.ColumnHeader
cursor="pointer"
onClick={() => handleSort('totalAttempts')}
_hover={{ bg: 'gray.50' }}
textAlign="right"
>
{t('challenge.admin.detailed.stats.tasks.table.attempts')} {sortKey === 'totalAttempts' && (sortDirection === 'asc' ? '↑' : '↓')}
</Table.ColumnHeader>
<Table.ColumnHeader
cursor="pointer"
onClick={() => handleSort('uniqueUsers')}
_hover={{ bg: 'gray.50' }}
textAlign="right"
>
{t('challenge.admin.detailed.stats.tasks.table.users')} {sortKey === 'uniqueUsers' && (sortDirection === 'asc' ? '↑' : '↓')}
</Table.ColumnHeader>
<Table.ColumnHeader
cursor="pointer"
onClick={() => handleSort('acceptedCount')}
_hover={{ bg: 'gray.50' }}
textAlign="right"
>
{t('challenge.admin.detailed.stats.tasks.table.completed')} {sortKey === 'acceptedCount' && (sortDirection === 'asc' ? '↑' : '↓')}
</Table.ColumnHeader>
<Table.ColumnHeader
cursor="pointer"
onClick={() => handleSort('successRate')}
_hover={{ bg: 'gray.50' }}
textAlign="right"
>
{t('challenge.admin.detailed.stats.tasks.table.success.rate')} {sortKey === 'successRate' && (sortDirection === 'asc' ? '↑' : '↓')}
</Table.ColumnHeader>
<Table.ColumnHeader
cursor="pointer"
onClick={() => handleSort('averageAttemptsToSuccess')}
_hover={{ bg: 'gray.50' }}
textAlign="right"
>
{t('challenge.admin.detailed.stats.tasks.table.avg.attempts')} {sortKey === 'averageAttemptsToSuccess' && (sortDirection === 'asc' ? '↑' : '↓')}
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{sortedTasks.map((task) => (
<Table.Row key={task.taskId}>
<Table.Cell fontWeight="medium">{task.title}</Table.Cell>
<Table.Cell textAlign="right">{task.totalAttempts}</Table.Cell>
<Table.Cell textAlign="right">{task.uniqueUsers}</Table.Cell>
<Table.Cell textAlign="right">{task.acceptedCount}</Table.Cell>
<Table.Cell textAlign="right">
<Badge colorPalette={getSuccessRateColor(task.successRate)}>
{task.successRate}%
</Badge>
</Table.Cell>
<Table.Cell textAlign="right">
{task.averageAttemptsToSuccess.toFixed(1)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
</Box>
)
}

View File

@@ -113,7 +113,7 @@ export interface SystemStats {
// API Request/Response types
export interface APIResponse<T> {
error: any
error: unknown
data: T
}
@@ -139,3 +139,87 @@ export interface UpdateChainRequest {
taskIds?: string[]
}
// ========== Stats v2 Types ==========
export type TaskProgressStatus = 'not_started' | 'pending' | 'in_progress' | 'needs_revision' | 'completed'
export interface TaskTableItem {
taskId: string
title: string
totalAttempts: number
uniqueUsers: number
acceptedCount: number
successRate: number
averageAttemptsToSuccess: number
}
export interface ChainProgress {
chainId: string
chainName: string
totalTasks: number
completedTasks: number
progressPercent: number
}
export interface ActiveParticipant {
userId: string
nickname: string
totalSubmissions: number
completedTasks: number
chainProgress: ChainProgress[]
}
export interface TaskProgress {
taskId: string
taskTitle: string
status: TaskProgressStatus
}
export interface ParticipantProgress {
userId: string
nickname: string
taskProgress: TaskProgress[]
completedCount: number
progressPercent: number
}
export interface ChainTask {
taskId: string
title: string
description: string
}
export interface ChainDetailed {
chainId: string
name: string
totalTasks: number
tasks: ChainTask[]
participantProgress: ParticipantProgress[]
}
export interface SystemStatsV2 {
// Базовая статистика из v1
users: number
tasks: number
chains: number
submissions: {
total: number
accepted: number
rejected: number
pending: number
inProgress: number
}
averageCheckTimeMs: number
queue: {
queueLength: number
waiting: number
inProgress: number
maxConcurrency: number
currentlyProcessing: number
}
// Новые данные v2
tasksTable: TaskTableItem[]
activeParticipants: ActiveParticipant[]
chainsDetailed: ChainDetailed[]
}